Build a semantic token system that supports dark and light modes, respects system preferences, and maintains WCAG AA contrast ratios across both color schemes.
Build a semantic token system that supports dark and light modes, respects system preferences, and maintains WCAG AA contrast ratios across both color schemes.
The foundational insight: components must never reference palette tokens directly. A component using --global-color-gray-900 must be manually updated when dark mode inverts values. Instead, components reference semantic tokens whose values flip between modes automatically via CSS media queries.
1/* Global palette — never use directly in components */
2:root {
3 --global-color-gray-50: #f9fafb;
4 --global-color-gray-900: #111827;
5 --global-color-gray-950: #030712;
6 --global-color-blue-400: #60a5fa;
7 --global-color-blue-600: #2563eb;
8}
9
10/* Semantic tokens — components reference only these */
11:root {
12 --color-surface-default: var(--global-color-white);
13 --color-surface-raised: var(--global-color-gray-50);
14 --color-text-primary: var(--global-color-gray-900);
15 --color-text-secondary: var(--global-color-gray-600);
16 --color-border-default: var(--global-color-gray-200);
17 --color-interactive-primary: var(--global-color-blue-600);
18}
19
20/* Dark mode: flip semantic tokens only */
21@media (prefers-color-scheme: dark) {
22 :root {
23 --color-surface-default: var(--global-color-gray-950);
24 --color-surface-raised: var(--global-color-gray-900);
25 --color-text-primary: var(--global-color-gray-50);
26 --color-text-secondary: var(--global-color-gray-400);
27 --color-border-default: var(--global-color-gray-700);
28 --color-interactive-primary: var(--global-color-blue-400);
29 }
30}Users should override the system preference without a page reload. A data-color-scheme attribute takes priority over the media query:
1/* Manual overrides take priority over prefers-color-scheme */
2[data-color-scheme='light'] {
3 --color-surface-default: #ffffff;
4 --color-text-primary: #111827;
5 --color-interactive-primary: #2563eb;
6}
7
8[data-color-scheme='dark'] {
9 --color-surface-default: #030712;
10 --color-text-primary: #f9fafb;
11 --color-interactive-primary: #60a5fa;
12}1// hooks/useColorScheme.ts
2import { useState, useEffect } from 'react';
3
4type Scheme = 'light' | 'dark' | 'system';
5
6export function useColorScheme() {
7 const [scheme, setScheme] = useState<Scheme>(
8 () => (localStorage.getItem('color-scheme') as Scheme) ?? 'system'
9 );
10
11 useEffect(() => {
12 const el = document.documentElement;
13 if (scheme === 'system') {
14 el.removeAttribute('data-color-scheme');
15 } else {
16 el.setAttribute('data-color-scheme', scheme);
17 }
18 localStorage.setItem('color-scheme', scheme);
19 }, [scheme]);
20
21 return { scheme, setScheme };
22}WCAG AA requires 4.5:1 contrast for normal text and 3:1 for large text and UI components. Dark mode color choices often fail this check because designers pick values that feel right without measuring. Add contrast verification to CI so failures block the PR:
1// scripts/verify-contrast.mjs
2import { APCAcontrast, sRGBtoY } from 'apca-w3';
3import { tokensByMode } from './resolve-tokens.mjs';
4
5const PAIRS = [
6 {
7 label: 'Body text on surface',
8 bg: '--color-surface-default',
9 fg: '--color-text-primary',
10 minRatioWCAG: 7.0,
11 },
12 {
13 label: 'Secondary text on surface',
14 bg: '--color-surface-default',
15 fg: '--color-text-secondary',
16 minRatioWCAG: 4.5,
17 },
18 {
19 label: 'Button text on interactive background',
20 bg: '--color-interactive-primary',
21 fg: '--color-surface-default',
22 minRatioWCAG: 3.0,
23 },
24];
25
26for (const mode of ['light', 'dark']) {
27 const tokens = tokensByMode(mode);
28 for (const pair of PAIRS) {
29 const ratio = getContrastRatio(tokens[pair.bg], tokens[pair.fg]);
30 if (ratio < pair.minRatioWCAG) {
31 console.error(`FAIL [${mode}] ${pair.label}: ${ratio.toFixed(2)} < ${pair.minRatioWCAG}`);
32 process.exit(1);
33 }
34 }
35}
36console.log('All contrast checks passed for light and dark modes.');Windows High Contrast mode replaces all colors with system palette values. Background images, border colors, and box shadows disappear. Design for forced-colors by adding explicit borders and using currentColor for icons:
1.button-primary {
2 background: var(--color-interactive-primary);
3 color: var(--color-surface-default);
4 border: 2px solid transparent;
5 box-shadow: 0 0 0 3px var(--color-interactive-primary);
6}
7
8@media (forced-colors: active) {
9 .button-primary {
10 /* Use system ButtonFace and ButtonText colors */
11 background: ButtonFace;
12 color: ButtonText;
13 border-color: ButtonText;
14 /* Remove box-shadow — invisible in forced-colors */
15 box-shadow: none;
16 }
17
18 .button-primary:focus-visible {
19 /* Use system Highlight color for focus rings */
20 outline: 3px solid Highlight;
21 outline-offset: 2px;
22 }
23
24 .icon {
25 /* currentColor inherits ButtonText automatically */
26 fill: currentColor;
27 stroke: currentColor;
28 }
29}Test forced-colors in Windows Settings > Ease of Access > High Contrast, or use Chrome DevTools Rendering > Emulate CSS media feature forced-colors.
A Storybook global decorator lets designers preview both color schemes without leaving the component catalog:
1// .storybook/preview.tsx
2import type { Decorator, Preview } from '@storybook/react';
3import '../packages/tokens/dist/global.css';
4import '../packages/tokens/dist/themes/light.css';
5import '../packages/tokens/dist/themes/dark.css';
6
7const withColorScheme: Decorator = (Story, context) => {
8 const colorScheme = context.globals.colorScheme ?? 'light';
9 return (
10 <div
11 data-color-scheme={colorScheme}
12 style={{
13 background: 'var(--color-surface-default)',
14 color: 'var(--color-text-primary)',
15 padding: '1.5rem',
16 minHeight: '100vh',
17 }}
18 >
19 <Story />
20 </div>
21 );
22};
23
24export const decorators = [withColorScheme];
25
26export const globalTypes = {
27 colorScheme: {
28 name: 'Color Scheme',
29 defaultValue: 'light',
30 toolbar: {
31 icon: 'sun',
32 items: [
33 { value: 'light', title: 'Light', icon: 'sun' },
34 { value: 'dark', title: 'Dark', icon: 'moon' },
35 ],
36 dynamicTitle: true,
37 },
38 },
39};Audit your current color tokens and identify any components referencing palette tokens directly. Migrate them to semantic tokens, add a contrast verification script to CI, and add the Storybook color scheme decorator.
Your progress is saved in this browser, on this device only.