A framework-free, accessible design system made of CSS custom properties, native HTML, and one small TypeScript helper. The React wrapper is optional. Storybook is the workbench.
Appearance has three independent axes:
| Axis | Attribute | Owns |
|---|---|---|
| Chrome | data-chrome |
Feel: fonts, case, tracking, radius, border width, density, shadows, press motion, focus style, easing, effects |
| Palette | data-palette |
Color roles: surfaces, text, brand, status, borders, focus ring |
| Mode | data-mode |
light, dark, or system |
Any chrome pairs with any palette. Chromes: calm, glass, bubblegum, holoterminal, expressive, darkmatter. Palettes: default, aurora, sorbet, calm-storm, cyberpunk, cotton-candy, caffeine, singularity. You can also generate a palette from your own brand colors (see Custom palettes).
bun install
bun run storybook # http://localhost:6006
bun test # token contract, WCAG contrast, palette generator, color math
bun run test:browsers # Playwright: the axe sweep and behavior checks in Firefox and WebKittest:browsers needs Playwright's own browsers once: bunx playwright install firefox webkit. It builds a static Storybook, runs it in both engines, and writes an HTML report with screenshots to .verify/playwright/report.
Install the package.
bun add odd-designThe stylesheet, odd-design/appearance, odd-design/enhance, and odd-design/palette need nothing else. Install React 19 when you render Button, the Field parts, the Choice parts, Badge, the Alert parts, or the Avatar parts. React is an optional peer dependency.
bun add react@^19 react-dom@^19Set up the checkers the docs describe.
- Types come from
odd-design/react. See Use the React components. - ESLint loads
odd-design/eslint. See Check HTML with ESLint. - Tests call
registerUsageChecksfromodd-design/usage/test-setup. See Set up the runtime checker.
Add this line to your AGENTS.md.
Fix every odd-design ODD- code before you finish. TypeScript, ESLint, and tests all report them.
<html data-chrome="calm" data-palette="default" data-mode="system">
<head>
<script>/* FIRST_PAINT_SCRIPT from src/js/appearance.ts */</script>
<link rel="stylesheet" href="odd-design/src/css/index.css">
</head>
<body>
<button class="odd-button" data-variant="primary">Save</button>
</body>
</html>Fonts are not bundled. Load Outfit, Bricolage Grotesque, and JetBrains Mono (for example with @fontsource-variable/*), or the stacks fall back to system fonts.
A subtree can have its own appearance: add class="odd-scope" and set all three attributes on it.
src/css/index.css declares the cascade layers in order:
foundations: fixed scales (type, space, durations, z-index), derived tokens, and default status colors.palette:palettes/*.css. Each color islight-dark(light, dark), so mode is justcolor-schemeandsystemfollows the OS without JS.chrome:chromes/*.css. Feel tokens only. A chrome can reference palette colors, such as a primary-tinted glow, but never contains a hue of its own.preferences:prefers-contrast,prefers-reduced-motion,prefers-reduced-transparency, andforced-colors. This layer beats palette and chrome.base: element defaults.components:.odd-*classes. These read tokens and never check which chrome or palette is active.
src/js/contract.ts lists every palette token, every chrome token, and the WCAG pairs. The tests read it, so adding a token to the contract fails bun test until every palette or chrome defines it.
- Every palette defines all color roles and nothing else.
- Every palette passes WCAG 2.2 AA in light and dark: 4.5:1 for text (including links and error text on surfaces), and 3:1 for input borders and focus rings.
- Text on every soft tint (
--tint-*, used by badges, alerts, avatars, and selected table rows) passes 4.5:1 in light and dark. The test evaluates the samecolor-mix()the browser does. - Every chrome defines all feel tokens, defines no color roles, and contains no chromatic color literal.
- No component selector mentions
data-chromeordata-palette. - The tint and status-color values in
foundations.cssmatch the contract, which the palette generator also reads. - The palette generator meets every contrast pair for 146 seed pairs across the hue wheel, checked both by its own math and through the CSS it emits.
- Text on every chrome's page background passes AA at the background's worst points (each solid color under every combination of its washes), for every palette and mode. Each chrome's
--page-backgroundmatchesPAGE_BACKGROUNDSin the contract, which the palette generator also reads. src/js/color.tsagrees with culori on parsing, contrast, gamut clamping, mixing, and compositing.
One rule set, defined once in .storybook/a11y.ts: stock axe-core, WCAG 2.0, 2.1, and 2.2 at levels A and AA, with no custom rules. The Storybook a11y panel reads it through parameters.a11y, and the verify harness's sweep imports it, so both apply the same rules.
bun .agents/skills/verify-odd-design/scripts/ctl.ts up --run a11y
bun .agents/skills/verify-odd-design/scripts/ctl.ts axe --run a11y # every story, light and dark, overlays opened- The sweep fails on any violation. Results axe couldn't decide are printed as
REVIEWwith axe's reason. .storybook/a11y-rules.txtrecords the 63 rules the tags resolve to on the pinned axe-core. The sweep fails if the rules that ran differ, so an axe upgrade that adds or drops rules shows up as a diff to review. Accept it with--update-rules.- axe can't measure contrast over gradients, so text on page backgrounds is proven by
bun testinstead. bun run test:browsersruns the same sweep, with the same rules and the same record, in Firefox and WebKit. Chrome stays on the harness.
The package ships the button, field, choice, badge, alert, and avatar usage contracts and one message per pinned code. Each message names the code, the failure, and the fix. Types, ESLint, and the runtime checker report the same codes. The pages under docs/usage/ record the rules. Button codes are in Button usage rules. Field codes are in Field usage rules. Choice codes are in Choice usage rules. Badge codes are in Badge usage rules. Alert codes are in Alert usage rules. Avatar codes are in Avatar usage rules. Class and static codes are in General usage rules.
- Copy
src/css/palettes/aurora.css, rename the selector, and edit the colors. Or generate one from brand colors withpaletteCss(see Custom palettes). - Add the id to
PALETTESinsrc/js/contract.tsand import the file inindex.css. - Run
bun testand fix any contrast failures it reports.
odd-design/palette (src/js/palette.ts) builds a full palette, light and dark, from one or two brand colors and holds it to the same contract as the shipped palettes.
import { applyPalette, generatePalette, paletteCss } from "odd-design/palette";
const palette = generatePalette({ primary: "#7c3aed", secondary: "#f472b6" });
applyPalette(palette); // defines [data-palette="custom"] in the palette layer
document.documentElement.dataset.palette = "custom";
paletteCss("my-brand", palette); // or ship it as a file like src/css/palettes/*.cssEvery role is laid out from the seeds, then repairPalette moves the lightness of any role that fails a contrast pair until all pass. Hue and chroma stay as given, so a pale brand color may come out darker in light mode: a pale yellow becomes olive there, because yellow link text can't reach 4.5:1 on a light page. Try it in Foundations → Custom Palette. The color math is dependency-free.
The five palettes ported from uvs-decks-gg went through the same repair. Their input borders were darkened to 3:1, a few primaries and danger tones were nudged, and white text on danger in dark mode became dark text.
- Copy the chrome closest to what you want, rename the selector, and edit the values.
- Add the id to
CHROMESand import the file inindex.css. - Run
bun test, then check Showcase → Matrix in Storybook.
-
Add the part's
WRAPPERSrow and itsUSAGErules tosrc/js/usage/contract.ts. Add each new code toUSAGE_CODESandMESSAGESinsrc/js/usage/format.ts. A class's rules run only when itsUSAGEgroup has anelementrule for it. Each data prop in the row needs anenumorboolean-attributerule, which types the prop. The row'spropsare also the part's owndata-*names, which asystem-datarule allows and its message lists. -
Document each code in
docs/usage/<family>.md. -
Write
src/react/<family>.tsx. A part with a fixed tag needs three declarations.Contracttypes its props from its rules, andpartrenders its row.interface BadgeContract extends Contract<"Badge", BadgeRule> {} export interface BadgeProps extends BadgeContract, Native<"Badge", keyof BadgeContract> {} export const Badge: (props: BadgeProps) => ReactElement = part("Badge");
-
Export the module from
src/react/index.ts, and add the props toWrapperPropsinsrc/react/props.ts. -
Mirror the part's stories in
stories/mirrored/<Family>.stories.ts. -
Add the part's rows to the
markuptable intests/react.test.tsx. Add a<part>-<rule>.tsxfail fixture intests/fixtures/types/fail/for each code its props reject, with itsfailuresentry. Add theplacesentries for its ESLint fixtures intests/eslint-jsx.test.tsandtests/eslint-html.test.ts. Add a<code>.bad.htmland<code>.good.htmlpair intests/fixtures/usage/for each runtime code, with the code in lower case, and list the code inRUNTIME_CODESintests/usage-check.test.ts. Pin the mirrored story ids intests/stories.test.ts.
scripts/build.ts and scripts/consumer-smoke.ts read the odd-design/react barrel, so a new part adds no line to either. A new usage page goes in REQUIRED in scripts/consumer-smoke.ts.
| Class | Element | Notes |
|---|---|---|
odd-button |
<button>, <a> |
data-variant, data-size, data-icon-only. Use aria-disabled and aria-busy. |
odd-field, odd-label, odd-input, odd-textarea, odd-select, odd-hint, odd-error |
native form controls | Errors style on :user-invalid. The select upgrades through appearance: base-select. |
odd-choice, odd-switch |
checkbox, radio, role="switch" |
|
odd-segmented |
radio <fieldset> |
Arrow keys come from the browser. |
odd-card |
<article> |
data-interactive plus .odd-card-link makes the whole card clickable through a single link. |
odd-badge |
<span> |
data-tone, data-solid. |
odd-dialog |
<dialog> |
Open with commandfor/command="show-modal", no JS. |
odd-disclosure |
<details name> |
An exclusive accordion. |
odd-heading, odd-eyebrow |
<h1>–<h6>, <p> |
data-size (xs–2xl, hero) sets size apart from level. The eyebrow takes data-tone. |
odd-alert, odd-alert-title |
<div>, <p> |
data-tone is info, success, warning, or danger. Leaving it out is the neutral tint. The title is a p. Static alerts need no role; insert later ones into an existing role="status" or role="alert" container. |
odd-progress, odd-meter, odd-stat |
<progress>, <meter> |
A meter with low/high/optimum colors itself by range. Always show the value as text too. |
odd-skeleton |
<div aria-hidden> |
Put aria-busy and "Loading" text on the region. data-shape="avatar". |
odd-separator |
<hr> |
aria-orientation="vertical" inside a row. |
odd-avatar, odd-avatar-group |
<span> with <img alt> or role="img" initials, <div role="group"> with a name |
data-size on each avatar. |
odd-table-wrap, odd-table, odd-table-sort |
<table> |
The wrapper scrolls and is focusable. data-numeric, aria-sort, and a row checkbox tints the row. |
odd-slider |
<input type="range"> |
The filled track follows the thumb with no JS where scroll-driven animations exist. |
odd-search |
<search> with <input type="search"> |
Visually hidden label, leading icon. |
odd-popover |
<div popover role="dialog"> |
Open with commandfor/command="toggle-popover". Anchors to the invoker and flips. Use role="dialog" and a name when it holds controls. |
odd-tooltip |
<div popover="hint" role="tooltip"> |
Shown via interestfor. Wire aria-labelledby or aria-describedby yourself. |
odd-hover-card |
<div popover="hint"> |
A preview via interestfor. Its content must also be reachable through the link. |
odd-sheet |
<dialog> |
Opens with command="show-modal"; `data-side="start |
| alert dialog | <dialog class="odd-dialog" role="alertdialog"> |
Leave out closedby="any" so only Escape or a button closes it. |
odd-menu, odd-menu-item |
<div popover role="menu"> |
Needs enhanceMenu. menuitemcheckbox/menuitemradio with aria-checked; data-tone="danger"; disable items with aria-disabled="true", which keeps them reachable. |
odd-tabs |
role="tablist", tab, tabpanel |
Needs enhanceTabs. data-activation="manual", aria-orientation="vertical". |
odd-toaster, odd-toast |
built by createToaster() |
A polite live region in the corner; it moves into an open modal dialog so toasts stay live. |
odd-estop |
<button> |
A big round stop. aria-pressed="true" draws it latched; the page sets it and owns any shortcut. Never disable it. |
odd-stepper |
<div role="group"> with step buttons around <input type="number"> |
Needs enhanceStepper. data-step counts input steps. Hold repeats; input per step, one change per press. Disable with disabled on the input. |
odd-range-field |
<div> holding an odd-slider, an odd-input type="number", and an optional unit |
enhanceRangeField copies min, max, and step from the slider and keeps both in sync. Listen to the slider. A bad number shows an odd-error on commit. |
odd-log, odd-log-line |
<div role="log" tabindex="0"> of line <div>s, each with an optional <time> |
Mono, scrolls past 20rem. data-tone on a line. Append with appendLogLine; enhanceLog keeps the newest line in view unless the reader scrolled up. |
odd-shell, odd-shell-header, odd-shell-status, odd-shell-actions, odd-shell-main |
<div>, <header>, <main> |
A full-height page under a sticky header bar: title, a status slot that takes the free space, and actions. |
odd-grid |
any container, often <main class="odd-shell-main odd-grid"> |
12 columns. data-span="1"–"12" on a child, usually an odd-card. Under 64rem of grid width, narrow children take half; under 40rem, all take the full row. --gap sets the gutter. |
Menus, tabs, toasts, steppers, range fields, and logs need behavior HTML doesn't have yet, so odd-design/enhance (src/js/enhance.ts) adds them on top of native markup. Each function takes an element and returns a cleanup function. No dependencies. There is no framework, and the React wrapper is optional.
import { enhance, createToaster } from "odd-design/enhance";
enhance(); // menus, tab sets, steppers, range fields, logs, select-all tables, and the fallbacks below, under document
const toaster = createToaster();
toaster.show({ title: "Deck saved", tone: "success" });enhanceMenu(menu),enhanceTabs(root),enhanceSelectAll(table),enhanceStepper(root),enhanceRangeField(root, { message }),enhanceLog(log): one widget each. Call them for elements added afterenhance().createToaster({ label, max, parent }):show({ title, description, tone, duration, action })returns{ dismiss }.appendLogLine(log, text, { tone, time, max }): adds a line as text, withtimeas aDateor a string, and drops the oldest lines pastmax(500).avatarFallback(): an.odd-avatar[data-initials]whose image fails to load shows its initials. It replaces the avatar's children, so use it on markup the page owns, not on a ReactAvatar.interestForFallback()andclosedByFallback(): tooltips and hover cards, andclosedby="any", in browsers without them. They do nothing where the browser has the feature.roving(container, options): the shared arrow-key, Home/End, and typeahead helper the widgets use.