Define colour tokens for light and dark schemes
Canonical URL: https://devexamples.com/css/define-color-tokens-for-light-and-dark-schemes/
The problem
A stylesheet that repeats hex values everywhere cannot keep a light theme and a dark theme in step. The scheme has to flip from one place, native form controls and scrollbars have to flip with it, and the resulting text and border pairs must clear WCAG 2.2 AA contrast in both schemes rather than only in the one that was designed first.
Short answer
Define intent-named custom properties and color-scheme on :root, then redefine only the token values inside a prefers-color-scheme: dark block. Components reference tokens, never hex values, and every text pair is checked against the surface it renders on.
Two schemes are one set of names with two value assignments. The components in between should never learn that a dark theme exists; they ask for --de-fg and get whatever the active scheme decided it means, which is the property that keeps a growing stylesheet from drifting out of sync with itself.
:root {
color-scheme: light dark;
--de-bg: #ffffff;
--de-surface: #f4f5f7;
--de-field: #e2e5ea;
--de-fg: #1a1c20;
--de-fg-muted: #4a5568;
--de-border: #c7ccd4;
--de-field-border: #6b7684;
--de-link: #0b57d0;
--de-focus-ring: #0b57d0;
--de-focus-ring-inner: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--de-bg: #12151a;
--de-surface: #1b2028;
--de-field: #232a34;
--de-fg: #e8ebf1;
--de-fg-muted: #a8b3c2;
--de-border: #3a424e;
--de-field-border: #8b97a8;
--de-link: #8ab4f8;
--de-focus-ring: #8ab4f8;
--de-focus-ring-inner: #12151a;
}
}.page {
background-color: var(--de-bg);
color: var(--de-fg);
}
.card {
background-color: var(--de-surface);
border: 1px solid var(--de-border);
color: var(--de-fg);
}
.note {
color: var(--de-fg-muted);
}
.field {
background-color: var(--de-field);
border: 1px solid var(--de-field-border);
color: var(--de-fg);
}
a {
color: var(--de-link);
text-underline-offset: 0.125em;
}ratio = (L1 + 0.05) / (L2 + 0.05), WCAG 2.2 relative luminance
text, needs 4.5:1 or better
#1a1c20 on #ffffff 17.06 body text, light
#1a1c20 on #f4f5f7 15.64 body text on a card, light
#4a5568 on #ffffff 7.53 secondary text, light
#4a5568 on #f4f5f7 6.90 secondary text on a card, light
#4a5568 on #e2e5ea 5.96 helper text on a field fill, light
#0b57d0 on #ffffff 6.39 link text, light
#0b57d0 on #f4f5f7 5.85 link text on a card, light
#e8ebf1 on #12151a 15.32 body text, dark
#e8ebf1 on #1b2028 13.70 body text on a card, dark
#a8b3c2 on #12151a 8.62 secondary text, dark
#a8b3c2 on #1b2028 7.70 secondary text on a card, dark
#a8b3c2 on #232a34 6.81 helper text on a field fill, dark
#8ab4f8 on #12151a 8.68 link text, dark
non-text, needs 3:1 or better
#6b7684 against #ffffff 4.62 field border on the page, light
#6b7684 against #e2e5ea 3.66 field border on its own fill, light
#8b97a8 against #12151a 6.18 field border on the page, dark
#8b97a8 against #232a34 4.88 field border on its own fill, dark
#0b57d0 against #ffffff 6.39 focus ring, light
#8ab4f8 against #12151a 8.68 focus ring, darkExplanation
Custom properties are resolved at use time and inherit down the tree, which is what makes this pattern work with one rule per scheme. Every component rule above reads a name, so flipping the scheme changes the answer without changing a question. A preprocessor variable cannot do this: it is substituted at build time, so a second scheme means a second compiled stylesheet and two opportunities for them to diverge. color-scheme is a separate concern and is easy to misread as the switch, but it does nothing to these values. It tells the browser which of its own palettes it may use when painting the root’s default canvas, native form controls, and scrollbars, which is why a dark page without it keeps light-rendered select menus and a light scrollbar next to dark content.
The token names carry intent rather than colour, so --de-fg-muted survives being re-hued in a future palette while --de-grey-600 would be a lie the moment it moves. The palette is deliberately split by job: --de-bg, --de-surface and --de-field are paints, --de-fg and --de-fg-muted are text, and the two border tokens exist because decorative separation and control identification have different obligations. --de-border sits at 1.61:1 against the light page background and is only ever used on a card whose edge is not the sole cue that the card exists. --de-field-border is the one that must be visible on its own: at 4.62:1 against the page and 3.66:1 against the field fill it clears the 3:1 non-text requirement of SC 1.4.11 at both adjacencies, and the focus ring tokens pass the same test at 6.39:1 and 8.68:1.
The boundary condition to keep in mind is that a media query adds no specificity, so the dark block has to come after the light declarations to win, and a scheme override that lives in a later layer or a more specific selector will beat it silently. The other one is scale: the ratios above hold for body-size text, and while SC 1.4.3 relaxes to 3:1 for rendered text at 24px normal or roughly 19px bold, nothing in this palette relies on that exemption. A manual toggle should therefore replace both halves of the contract at once, setting color-scheme and the values on the same element, because dark tokens paired with a light scheme declaration give you a dark page with light-native widgets and a helper text colour nobody checked.
Usage notes
- Put the dark block after the light declarations: the media query adds no specificity, so the later rule is what wins.
- A manual theme toggle should set color-scheme and the token values on the same element, otherwise dark tokens can drive light-rendered form controls.
- Check contrast between the two colours that actually meet at render time, not between a token and the page background the component happens to sit on.
Common mistakes
- Treating color-scheme as the token switch and wondering why the page colours never change.
- Darkening secondary text to a grey that clears 3:1 and reading as disabled, because muted text is still text.
- Shipping one token set and inverting every component rule instead of re-pointing the values in one place.
Caveats
- Custom properties inherit, so a token redefined on a subtree applies to everything inside it; that is powerful and is also how a component-local override silently changes its children.
- Below 4.5:1 a text token is only acceptable for genuinely large text, and the large-text threshold is 24px normal or about 19px bold rendered text, not a class name.
Follow-up
Follow-up
Style a visible keyboard focus ring with custom propertiesFocus styling reads these same tokens, so the ring stays correct in both schemes.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Centre an element with flexbox and no wrapper markupLayout rules consume the custom properties declared here.
Same task or topic
Style a visible keyboard focus ring with custom propertiesSolves the Create CSS design tokens task
Same task or topic
Build a card grid that reflows without media queriesShares the CSS Layout topic
References
- prefers-color-scheme - CSS | MDN(opens in a new tab) — MDN Web Docs. Media feature definition and the schemes browsers report as their applied preference.
- color-scheme - CSS | MDN(opens in a new tab) — MDN Web Docs. Explains which user-agent surfaces the property controls and what it never touches.
- Success Criterion 1.4.3 Contrast (Minimum)(opens in a new tab) — W3C. The 4.5:1 ratio and the large-text exception this token set is checked against.
Source page: https://devexamples.com/css/define-color-tokens-for-light-and-dark-schemes/