Systems · tokens, light and dark, contrast in CI
Three design systems, built the way this page is built.
Design tokens layered Style, then Brand, then Theme. Light and dark. WCAG AA contrast checked in CI. Storybook. The systems are private npm packages, so the specimen below is this site's own set, built the same way, and not one of theirs.
01Token bench · recolor the site, trip the guard
Every color on this page is a token. Move one.
Hue and lightness rewrite the Brand layer inside the sandbox. A WCAG AA check runs on the rendered colors, in both themes. Only a passing set can be merged onto the site, and only by you.
Style, then Brand, then Theme. The Switch in the status bar rewrites only the third.
Presets are hues only. Each product keeps its own token set.
The guard reads rendered rgb(), not the numbers I meant to ship.
Brand layer --wd-amber
presetshues only
Amber is the human color: the ruling, the primary action, the focus ring.
- --wd-amberthe fill
- --wd-amber-textaccent text
- --wd-on-amberthe label on the fill
CHK contrast · 5.9:1 text · 5.0:1 label · pass · both themes · rendered rgb()
Token layers · Style, then Brand, then Theme
- 01Stylespace, type, radius, motion--wd-space-05 to 12 · --wd-fs--1 to display · --wd-radius-0 to 3 · --wd-font-display, body, monorewritten bycode
- 02Brandthe accent and its pairs--wd-amber #A0560F · --wd-amber-text #8F4D10 · --wd-on-amber #F7F3ECrewritten bythe Token Bench
- 03Themepaper, ink, rule, the pass and fail states--wd-paper #F7F3EC · --wd-paper-2 #EFE9DF · --wd-ink #191714 · --wd-ink-2 #5C574F · --wd-rule #D9D2C5rewritten bythe Switch
02Systems · one component, six layers
A button is six decisions in a trench coat.
The same Button the rest of this site uses, taken apart. Every layer is a file I would have to get right for the component to be worth publishing.
On a wide screen, hover the stack and it opens so every layer reads. On a touch screen it rests open; below 960 px it stacks flat.
The stack is decorative; the layer nav under it is the control.
Hover the stack to open it.Choose a layer to read its excerpt.
01Primitive
Button.astro// this site's own Button:// an <a> with an href, a <button> otherwise<button class:list={['btn', variant, size]} type={type} {...rest}> <slot /></button>One element, no wrapper divs. Everything else is a layer on top of it.
02Tokens
button.css.btn { /* Style: size and shape */ min-height: 44px; padding: 0 var(--wd-space-3); border-radius: var(--wd-radius-2); /* Brand: the fill and its paired label */ background: var(--wd-amber); color: var(--wd-on-amber);}Style tokens for size and shape, Brand for the fill, Theme for the label. Nothing hard-coded, so the bench can rewrite one layer.
03States
button.css/* state what changes */:hover border --wd-ink-2:active scale(0.985):focus-visible 2px --wd-amber ring at a 3px offset[disabled] opacity 0.6 dashed border, no pressTransform and opacity only. No hover lift; the border and the pressed scale carry the state.
04Accessibility
button.a11y.mdrole button, the native elementname visible text, or aria-labelkeyboard Enter and Space tab order preservedfocus ring visible in both themescontrast label on fill, 4.5:1 minimum checked in CIA native element, so role, name and keyboard come for free and stay correct.
05Tests
Button.test.ts✓ renders with an accessible name✓ hover, focus and pressed states render✓ axe: no violations, light and dark✓ contrast: on-amber on amber, 4.5:1✓ disabled: no click, aria-disabled// sample: no test runner behind this pageVitest and axe, in both themes. The contrast line is the check this page runs on itself.
06Story
Button.stories.tsxexport default { title: 'Actions/Button', component: Button,};export const Primary = {};export const Ghost = { args: { variant: 'ghost' } };export const Small = { args: { size: 'sm' } };export const Disabled = { args: { disabled: true } };Every variant has a story, so review happens on the real component instead of a screenshot.
03Specimen · this site's own components
No product UI is public, so the specimen is this page.
Every control here is the component this site actually ships, on the same token layers as the systems: keyboard operable, 44 px targets, a visible amber focus ring, and both themes from one set.
Turn Inspect mode on and hover or tab any of these to read its tokens and contrast.
Tab through the sheet. Nothing here needs a mouse.
Button
44 px, amber only for the primary. The one taken apart above.
Switch
role=switch. The knob stretches on the press, then settles.
Segmented
A radiogroup with a roving tabindex. The thumb follows on a spring.
Input
The error names what is wrong instead of only turning red.
Add the domain, so I can write back.
Select
The native control, framed. The keyboard and the mobile picker stay.
Two lanes, no third.
Tabs
Arrow keys move and select. One tab stop for the whole control.
Tooltip
Hover or focus shows it. Esc dismisses it without moving focus.
Toast
One aria-live region for the page. Three at a time, then a queue.
Dialog
The native element, so Esc, the backdrop and the focus trap are free.
Kbd and Tag
The small type that carries the state of the page.
04Three systems · one token model
Three systems, and one of them I sent back.
Three accessible React design systems, 113-130 components each, with design tokens layered Style, then Brand, then Theme; light and dark; WCAG AA contrast checked in CI; Storybook; published as private npm packages.
113 to 130 components each, across three product lines.
Nothing links out: the packages are private.
Components per system113 to 130
Solid cells show the lower bound; outlined cells run to the upper.
- Shared systemTokens, light and dark, contrast in CI, Storybook. The one I had rebuilt.Tactile
- ChurchVueKept separate on purpose, so the product's identity stays its own.own system
- DwinterThe games' system, from the same token layers.games
Competent but generic.
One identity per product
I kept ChurchVue on its own system rather than the shared library, to keep each product's identity distinct. Each product keeps its own token set; the presets on the bench share hues, not identities.
- 2themes, light and dark, from one token setthe Theme layer of the tokens
- AAWCAG contrast, checked in CI on every builda standing contrast test