Design Tokens and Theming Without Duplicating Everything
Split tokens into primitives and semantic names, theme with light-dark() and a data attribute, and check contrast per theme. After this, a new theme is a few token values.
Course content
Sign in to save your progress.
-
Architecture That Scales
-
Modern CSS Without a Framework Crutch
-
Cascade Layers and Specificity You Control
-
-
-
Layout Systems for Real Sites
-
Complex Grids and Nested Layout Patterns
-
Container Queries When the Component Is the Breakpoint
-
-
-
Motion and Interaction Done Right
-
Transitions and Animations That Don't Annoy People
-
prefers-reduced-motion and Respecting the User
-
-
-
Performance and Delivery
-
Critical CSS and What Blocks First Paint
-
Font Loading Without Layout Jump
-
Responsive Images: srcset, sizes and Lazy Load
-
-
-
Ship and Maintain
-
Design Tokens and Theming Without Duplicating Everything
-
CSS That Survives a Redesign
-
Audit Accessibility and Visual Regressions Before Launch
-
-
- Final Check: HTML and CSS Advanced
Skip ahead: If you already split tokens into primitives and semantic names and theme by changing the semantic layer only, skip to "What I measured", then continue.
Theming goes wrong when it means copying. A dark theme becomes a second stylesheet with every colour rule written twice, and the first time someone changes the light button, nobody remembers the dark one. Tokens fix that by moving the decision out of the component. The component says "use the surface colour". The theme decides what the surface colour is.
Two tiers
/* Tier 1: primitives. Names say what the value is. */
:root {
--blue-600: #034bff;
--blue-300: #7aa2ff;
--gray-900: #080b0e;
--gray-600: #626976;
--gray-50: #f2f8ff;
--white: #ffffff;
}
/* Tier 2: semantic tokens. Names say what the value is for. */
:root {
color-scheme: light dark;
--surface: light-dark(var(--white), var(--gray-900));
--text: light-dark(var(--gray-900), var(--gray-50));
--text-muted: light-dark(var(--gray-600), #a9b1be);
--accent: light-dark(var(--blue-600), var(--blue-300));
}
/* Manual override from a toggle, beats the OS setting. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
Components use only tier 2: background: var(--surface), color: var(--text). Nothing in a component names --blue-600. If a component reaches for a primitive, a theme cannot reach it.
How light-dark() works
light-dark(a, b) returns a when the used colour scheme is light and b when it is dark. The scheme comes from color-scheme. With color-scheme: light dark the browser follows the operating system. Setting color-scheme: dark on the root forces dark. That is the whole toggle: one attribute and two small rules. It also makes form controls and scrollbars switch with the page, which a hand-made palette does not do on its own.
What I measured
I loaded a page that used these tokens, with the OS set to each scheme in Chrome 154, then set data-theme to the opposite. The values are background, text, muted text and link colour.
OS light rgb(255, 255, 255) rgb(8, 11, 14) rgb(98, 105, 118) rgb(3, 75, 255)
toggle to dark rgb(8, 11, 14) rgb(242, 248, 255) rgb(169, 177, 190) rgb(122, 162, 255)
OS dark rgb(8, 11, 14) rgb(242, 248, 255) rgb(169, 177, 190) rgb(122, 162, 255)
toggle to light rgb(255, 255, 255) rgb(8, 11, 14) rgb(98, 105, 118) rgb(3, 75, 255)
The manual toggle beat the OS setting in both directions, and no component rule changed.
Check contrast per theme
A token pair that passes in light can fail in dark. I computed the contrast ratios for the pairs above with the WCAG formula. Muted text on white is 5.52:1 and muted text on the dark surface is 9.13:1. The link blue on white is 6.08:1, and the lighter blue on the dark surface is 7.93:1. Normal text needs 4.5:1 at level AA, so all four pass. Do this for every semantic pair, in every theme, before you ship it.
⚠ Warning: Do not give dark mode the same colours with the lightness flipped. Pure white text on a near-black surface can feel harsh, and saturated blues often need a lighter shade to stay readable.
Common mistakes
- Naming tokens after their colour (
--blue) in the semantic tier. When the accent changes to green, the name lies. - Defining tokens per component (
--card-bg,--modal-bg,--nav-bg) before you know they differ. Start with a few shared ones. - Storing the theme only in CSS and forgetting to remember the visitor's choice. A toggle should persist, for example in
localStorage. - Testing only your own operating system's setting. Switch both.
💡 Tip: Review the semantic tier like an API. Adding a token is cheap. Removing one that components depend on is a migration.
What happens if you skip this
You end up with a .dark copy of every rule. They drift apart, and the dark theme is the one nobody tests until a user complains about unreadable text.
Try it
Take three colours that your components use today and define them as semantic tokens in :root. Add a data-theme="dark" block that redefines only those three. Switch the attribute in DevTools.
If the dark theme needed a rule change in a component, what did the token get wrong?
About
Split tokens into primitives and semantic names, theme with light-dark() and a data attribute, and check contrast per theme. After this, a new theme is a few token values.
- Duration
- 5 min
- Last update
- 08/05/2026
Comments (0)
Sign in to commentNo comments yet.