Tags: web-dev concept

Theming

Date: 2026-08-16


Swapping the values behind semantic token names while every component stays untouched. If a theme requires changing a component, the semantic layer isn’t doing its job.


Theming is providing more than one set of values for the same set of semantic token names — light and dark, brand variants, white-label tenants — so that appearance changes without any component knowing.

COMPONENT      background: var(--surface)
                            ▲
                  never changes

LIGHT THEME    --surface: #ffffff
DARK THEME     --surface: #16181c
BRAND B        --surface: #faf7f2

The component references a role. The theme supplies the value. That indirection is the entire mechanism — Design Tokens.

The implementation

CSS custom properties, cascading from the root:

:root {
  --surface: #ffffff;
  --text: #16181c;
  --action: #1d4ed8;
}
 
:root[data-theme="dark"] {
  --surface: #16181c;
  --text: #f4f4f5;
  --action: #60a5fa;
}

Scoping to a subtree works identically, which is what makes an inverted section or a per-tenant area possible without a second stylesheet:

<footer data-theme="dark">
  <!-- everything inside re-themes -->
</footer>

Three states, not two

The mistake almost everyone makes. A theme preference has three values, and only two of them stamp an attribute:

"light"    explicit → data-theme="light"
"dark"     explicit → data-theme="dark"
"system"   the default → NOTHING is stamped
           only prefers-color-scheme separates
           light from dark

Which means the correct structure is three layers, in this order:

/* 1. full light palette on bare :root */
:root { --surface: #fff; … }
 
/* 2. dark under the media query,
      guarded so an explicit light wins */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --surface: #16181c; …
  }
}
 
/* 3. dark again for the explicit toggle */
:root[data-theme="dark"] {
  --surface: #16181c; …
}

Never define a colour only inside a media query or a [data-theme] block. A token that exists in one branch and not the other produces a variable resolving to nothing, which fails silently and inconsistently.

Avoiding the flash

A theme applied by JavaScript after paint shows the wrong theme first.

HTML parsed → CSS applied (light) → PAINT
                                      ↓ flash
JS runs → reads preference → sets dark

The fix is a small blocking inline script in <head> that reads the stored preference and sets the attribute before first paint. It’s one of very few places a render-blocking script is correct — The Critical Rendering Path.

What themes should and shouldn’t change

SAFE TO THEME
  colour
  shadow (dark themes need less)
  border colour and sometimes weight

DANGEROUS TO THEME
  spacing      → breaks layout
  type scale   → breaks rhythm
  radius       → usually fine, rarely needed
  component structure → not a theme

A theme that changes spacing is a different design system wearing a theme’s name. Keep the structural tokens fixed and vary the surface ones, or every layout must be tested per theme.

Dark mode specifics

Not an inversion — Colour Systems:

  • Lower chroma on dark surfaces. Saturated colours vibrate against dark backgrounds
  • Lighter accents. Contrast reverses, so the action colour goes up the scale rather than down
  • Shadows barely work. Elevation on dark surfaces is communicated by making things lighter, not by casting shadow
  • Avoid pure black and pure white. Maximum contrast causes halation and eye strain

Where it goes wrong

  • Parallel stylesheets. Two full sets of component CSS is double the maintenance and guaranteed drift
  • Hard-coded values surviving in components. One color: #333 and that component ignores every theme. Lint for literal colours in component files
  • Images and illustrations. SVGs using currentColor theme for free; raster assets need a second version, and this is usually forgotten until launch
  • Testing only one theme. Contrast, focus rings and disabled states all need checking per theme — WCAG