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, 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, the Avatar parts, Separator, Skeleton, the Card parts, Disclosure, Tabs, Search, Slider, Meter, Progress, Toaster, Popover, Dialog, DialogActions, Sheet, SheetHeader, SheetFooter, Menu, MenuItem, TableWrap, Table, TableSort, TextLink, Spinner, Combobox, ComboboxList, ComboboxOption, Breadcrumb, Kbd, Code, or Pre. 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, weight, 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, avatar, separator, skeleton, card, disclosure, tabs, search, slider, meter, toast, popover, dialog, sheet, menu, table, base utility, grid, shell, typography, link, spinner, combobox, breadcrumb, kbd, and code 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. Separator codes are in Separator usage rules. Skeleton codes are in Skeleton usage rules. Card codes are in Card usage rules. Disclosure codes are in Disclosure usage rules. Tabs codes are in Tabs usage rules. Search codes are in Search usage rules. Slider codes are in Slider usage rules. Meter and progress codes are in Meter usage rules. Toast codes are in Toast usage rules. Popover codes are in Popover usage rules. Dialog codes are in Dialog usage rules. Sheet codes are in Sheet usage rules. Menu codes are in Menu usage rules. Table codes are in Table usage rules. Scope and skip link codes are in Base utilities usage rules. Grid codes are in Grid usage rules. Shell codes are in Shell usage rules. Heading and eyebrow codes are in Typography usage rules. Link codes are in Link usage rules. Spinner codes are in Spinner usage rules. Combobox codes are in Combobox usage rules. Breadcrumb codes are in Breadcrumb usage rules. Kbd codes are in Kbd usage rules. Code and pre codes are in Code 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");
Contractdoes not type anenumrule on an attribute that is not in the row'sprops. Give each one its own line,attribute?: Values<Rule, "attribute">, asAvatardoes forroleandSeparatordoes foraria-orientationandrole. -
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-combobox, odd-combobox-list, odd-combobox-option |
<div> around an <input class="odd-input" role="combobox"> and a <div popover="manual" role="listbox"> of <div role="option">s |
A text field whose suggestions filter by prefix as you type. Needs enhanceCombobox, or Combobox from odd-design/react. The input takes aria-controls and aria-autocomplete="list". Disable an option with aria-disabled="true". data-filter="manual" on the list when your code filters. ComboboxList and ComboboxOption render the list and the options. |
odd-choice, odd-switch |
checkbox, radio, role="switch" |
|
odd-segmented |
radio <fieldset> |
Arrow keys come from the browser. |
odd-card, odd-card-title, odd-card-link, odd-card-footer |
<article>, <section>, or <div> |
The title is a heading at the level the page needs. data-interactive plus one .odd-card-link makes the whole card clickable through a single link, so the card holds no other link or button. |
odd-badge |
<span> |
data-tone, data-solid. |
odd-dialog, odd-dialog-body, odd-dialog-title, odd-dialog-actions |
<dialog> with a <div> or <form> body, a heading title, and a <div> of actions |
Open with commandfor/command="show-modal", no JS. Name it with aria-labelledby or aria-label. Leave out open. Dialog and DialogActions from odd-design/react render the dialog and the actions. |
odd-disclosure |
<details> |
The question is a <summary> directly inside it, and the answer is one element after it. Disclosures that share a name form an exclusive accordion. |
odd-link |
<a href> |
A text link. It looks like a bare a. aria-current="page" marks the current page with the text color and no underline. A target needs rel="noopener" and words that say it opens a new tab. TextLink from odd-design/react renders it. |
odd-breadcrumb |
<nav aria-label> around an <ol> of <li>s |
The trail to the current page. Each level with a page is an odd-link, and the last has aria-current="page". A level with no page is plain text. CSS draws the slashes, and a long trail wraps. Breadcrumb from odd-design/react renders the nav. |
odd-kbd |
<kbd> |
A key in running text, one kbd per key, named rather than drawn as a glyph. Never aria-hidden. A menu item's hint is odd-menu-shortcut instead. Kbd from odd-design/react renders it. |
odd-code, odd-pre |
<code> in a sentence, <pre> around a bare <code> |
Inline code on a chip, and a block whose long lines wrap instead of scrolling. Leave the inner code out for command output. Code and Pre from odd-design/react render them. |
odd-heading, odd-eyebrow |
<h1>–<h6>, <p> |
data-size (xs–2xl, hero) sets size apart from level. The eyebrow takes data-tone. |
odd-stack, odd-row |
any container | A column, or a row that wraps. --gap sets the gap. |
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. |
odd-muted, odd-visually-hidden, odd-skip-link, odd-scope |
any element, an <a href="#id"> for the skip link |
The skip link slides in on focus. odd-scope takes all three of data-chrome, data-palette, and data-mode. |
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-value |
<progress>, <meter>, and a <span> or <output> value |
data-tone is success, warning, danger, or info. A meter with low/high/optimum colors itself by range. Always show the value as text too. odd-stat-value sets it bold with tabular digits. Progress and Meter from odd-design/react render the bars. The Stat row recipe sets a label and a value above a bar. |
odd-skeleton |
<div aria-hidden="true"> |
Put aria-busy and "Loading" text on the region, not on the block. The block takes no role or name. data-shape="avatar". |
odd-spinner |
<span aria-hidden="true">, empty |
A ring 1em across in the text color. Put aria-busy and the loading words on the region, not on the spinner. A working button is a Button with aria-busy. Spinner from odd-design/react renders it. |
odd-separator |
<hr> |
aria-orientation="vertical" inside a row, not data-orientation. role takes only separator, and aria-hidden is not allowed. For a line that means nothing, use a border. |
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 |
<div> around a <table>, with sort <button>s |
The wrapper scrolls and is focusable. data-numeric goes on a th and its cells, aria-sort on the sorted th, and a row checkbox tints the row. enhanceSelectAll wires a data-select-all header box. TableWrap, Table, and TableSort from odd-design/react render the three, and Table runs enhanceSelectAll. |
odd-slider |
<input type="range"> |
The filled track follows the thumb with no JS where scroll-driven animations exist. Disable it with disabled, not aria-disabled. Slider from odd-design/react renders it and takes type="range". |
odd-search |
<search> with a direct visually hidden <label>, <svg aria-hidden="true">, and <input class="odd-input" type="search"> |
Put a <form> around it to submit. Search from odd-design/react renders the <search>. |
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, and neither when it only shows text. Popover from odd-design/react renders it. |
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, odd-sheet-header, odd-sheet-body, odd-sheet-footer |
<dialog> with a <div> header, a <div> or <form> body, and a <div> footer |
Opens with command="show-modal". Name it with aria-labelledby or aria-label. `data-side="start |
| alert dialog | <dialog class="odd-dialog" role="alertdialog"> |
Leave out closedby="any" so only Escape or a button closes it. Dialog with role="alertdialog" renders it. |
odd-menu, odd-menu-item |
<div popover role="menu"> |
Needs enhanceMenu, or Menu from odd-design/react. Items are <button>s with a menu item role, and MenuItem renders them. menuitemcheckbox and menuitemradio take aria-checked. data-tone="danger" goes on an item. Disable an item with aria-disabled="true", which keeps it reachable. |
odd-tabs |
<div> with a direct role="tablist", <button role="tab">, role="tabpanel" |
Needs enhanceTabs, or Tabs from odd-design/react. Each tab's aria-controls names its panel. data-activation="manual" and aria-orientation="vertical" go on the tablist. |
odd-toaster, odd-toast |
<section popover="manual" aria-label="Notifications">, once per page |
Needs enhanceToaster, or Toaster from odd-design/react. enhanceToaster builds the list and a polite live region inside. toast() adds each odd-toast. A toast shown while a modal dialog is open moves the list inside the dialog, so the toasts stay live. Toasts already showing when it opens stay behind it until the next toast(). |
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. |
A recipe is an arrangement of core classes for one task. It adds no class and no stylesheet. Each recipe has a page under docs/recipes/ and stories under Recipes in the HTML Storybook.
| Recipe | Built from | Behavior |
|---|---|---|
| Number stepper | An odd-row with role="group", odd-buttons with data-step, and an odd-input type="number" |
enhanceStepper |
| Slider with number | An odd-row holding an odd-slider, an odd-input type="number", and an optional odd-muted unit |
enhanceRangeField |
| Stat row | An odd-stack holding an odd-row of a label and an odd-stat-value, then an odd-meter or odd-progress |
None |
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, toast } from "odd-design/enhance";
enhance(); // menus, tab sets, toasters, logs, select-all tables, and the fallbacks below, under document
toast({ title: "Deck saved", tone: "success" });enhanceMenu(menu),enhanceTabs(root),enhanceToaster(region),enhanceSelectAll(table),enhanceLog(log): one widget each. Call them for elements added afterenhance().enhanceStepper(root)wires thebutton[data-step]s underrootto the first number input under it.enhance()does not find steppers, so call it on each one. The markup is in Number stepper.enhanceRangeField(root, { message })syncs the first range input and the first number input underroot, and shows anodd-errorfor a number the slider can't take.enhance()does not find these rows, so call it on each one. The markup is in Slider with number.enhanceTabs(root)wires a tab set once, however many times it is called. Each call returns its own cleanup, and the last cleanup unwires. A ReactTabsruns it on itself, soenhance()beside it does not wire the set twice.enhanceToaster(region)wires asection.odd-toasteronce, however many times it is called, asenhanceTabsdoes.toast({ title, description, tone, duration, action })shows a toast on the newest wired region still in the document and returns{ dismiss }. It throws when no region is wired.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.