Skip to Content
HTML and CSS Advanced: Styles That Hold Up in Production
Advanced
0 likes
Courses
HTML and CSS Advanced: Styles That Hold Up in Production

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.

Course Home

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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.