Theming without touching a single component

Two independent attributes on the root element give fourteen appearances from nine declarations — and the test that stops the generated CSS drifting from the registry it came from.

The measure of a theming system is not how good the themes look. It is what happens when someone asks for one more.

If adding a theme means editing components, the system does not work — it just has not been asked yet. The target to design against is: adding a theme is an edit to a registry and nothing else, and no component ever knows a theme exists.

No component names a colour

The whole thing rests on one rule with no exceptions. Every colour in every component comes from a semantic token:

.brand-mark__accent {
  color: var(--ds-accent);
}

Not a hex, not a palette name, not --ember-500. --ds-accent means "whatever the active accent is", and the component's job ends there.

The rule extends past CSS, which is where it usually breaks. The hero's canvas needs real colour values to write into pixel buffers, and the tempting shortcut is a hardcoded fallback next to the drawing code. Instead it reads the tokens from the document:

function readTheme() {
  const styles = getComputedStyle(document.documentElement);
  inkRgb = parseColour(styles.getPropertyValue('--ds-text'), inkRgb);
  accentRgb = parseColour(styles.getPropertyValue('--ds-raster-primary'), accentRgb);
}

with a MutationObserver on the root's attributes so a theme change re-reads rather than caching a stale value. That observer watching only one of the two attributes was, for a while, exactly why the canvas kept a stale accent after a switch — a good illustration that "no component names a colour" has to include the components that are not CSS.

Two axes, not one list of themes

The obvious model is a list of themes, each a complete palette. It works until you want a second dimension.

This site separates two things that were initially one:

  • Base theme owns background, surface, text, muted text, border, success and
  • error. Two of them: Ink & Ember, Slate & Volt.
  • Accent owns accent, accent contrast, focus ring, selection background and
  • text, and the raster highlight. Seven of them.

Composed as two attributes on the root element:

<html data-theme="ink-ember" data-accent="violet"></html>

Seven accents times two base themes is fourteen appearances. Written as fourteen CSS blocks, every future accent would be a fourteen-block edit and every future base theme a seven-block one. Written as two independent axes it is two declarations plus seven — nine — and each new member of either axis is exactly one more.

The mechanism is ordinary cascade. Accent scopes come later in the stylesheet and override only the accent group, so the two axes compose instead of competing. Base theme blocks still declare their own accent values as a no-JavaScript fallback: a document with a theme but no data-accent still renders a coherent palette.

There is one rule that keeps the composition honest, and it is enforced rather than documented: an accent scope may not declare a structural token. An accent that moved the background would make the two axes fight, and no amount of care prevents someone reaching for it later. A test asserts it instead.

Generate the CSS, then test that you did

Two sources of truth is the failure this design invites. The accents exist as a TypeScript registry — used by the status bar control, the contrast report and the tests — and as CSS scopes the browser actually applies. Maintaining both by hand means they drift, and the drift is silent because both halves are individually valid.

So the CSS is generated from the registry by a script, and the generated file is committed. That alone is not enough: a generated file that nobody regenerates is just a stale file with a comment on top. A unit test regenerates the output and asserts it matches what is committed, so forgetting to run the generator fails the build rather than shipping.

This is the same shape as the vendored font file, which is asserted byte-identical to the package it came from, and the same shape as contrast being a build gate rather than a review step. Any time a value exists in two places, the thing that keeps them together should be a test, not a habit.

Applying it before first paint

A theming system that flashes the wrong palette on every visit for a returning visitor is not finished, and this is the one place the architecture has to bend.

The stored preference has to be applied before the browser paints, which rules out React entirely — by the time an effect runs, the default is already on screen. It takes a synchronous inline script emitted as the first child of <body>, and its contents are injected from the theme data so the ids, the colours and the storage key cannot drift from the registry either.

The root element also carries the default theme explicitly rather than leaving it to :root. The selector then only ever rewrites an attribute that is already there, and "which theme is active" is answerable from the DOM at any moment — which is what makes it testable.

The details of why that script cannot be placed anywhere more natural are part of the client-budget story.

What it cost, and what it did not

The cost is real but small: a token layer to design, a generator, two tests, and the discipline to route every colour through a semantic name even when a hex would have been quicker.

What it bought is that adding Slate & Volt touched no component. Adding seven accents touched no component. Moving the theme control from the footer into the status bar was a change of location, not of mechanism, because nothing downstream knew where the attribute was being written from.

And one decision that looks like a limitation is worth defending: both themes are dark, and no light mode ships. A light theme doubles the contrast-audit surface — every required pair, for every theme, in both modes — for a variant that is not part of the argument this site makes. It stays out not because it would be hard, but because it would be the same two edits as any other theme, and can therefore wait until it is actually wanted.

That is the real test of the system. The reason not to add a theme should be that nobody needs it, never that it would be expensive.

All articles