3 Theming
L4stIdi0t edited this page 2026-09-14 21:13:23 +02:00

Theming, and the system theme

driftwood components never carry a colour of their own. They write semantic utilities — bg-card, text-muted-foreground, border-input, ring-ring — which theme.css maps onto CSS custom properties your app defines. That is why one token block re-themes the whole kit, and why no component contains a dark: variant or a hex value.

Three stylesheets, imported in this order:

@import 'tailwindcss';
@import 'tw-animate-css';             /* Dialog + Tooltip entrances */
@import '@l4st/driftwood/base.css';   /* element defaults, reduced-motion */

@import '@l4st/driftwood/tokens.css'; /* the default palette — or write your own */

@import '@l4st/driftwood/theme.css';  /* tokens -> utilities. Always last. */

theme.css last is the only ordering that matters: its @theme inline block has to see the token names to bind them to utilities.

The same screen in light and dark, split down a diagonal

One composition, rendered once. The right half is the identical DOM inside color-scheme: dark — every colour you see differ is a light-dark() token resolving the other way.

How the system theme works

tokens.css ships one palette that serves both themes, and lets the browser pick the branch:

:root {
	color-scheme: light dark;
	--card: light-dark(oklch(1 0 0), oklch(0.205 0.008 var(--hue)));
	/* … every token, same shape */
}

Two mechanisms, and both are load-bearing:

  • light-dark(light, dark) resolves to its first or second argument depending on the element's used color-scheme. Light and dark values live on the same line, so a token cannot be changed in one theme and forgotten in the other — which is exactly what happens with two duplicated blocks.
  • color-scheme: light dark declares that the page supports both and, with no override, follows the OS. It is also the part a prefers-color-scheme media query never gave you: it themes the browser's own furniture — scrollbars, native form controls, autofill backgrounds, the text caret, spellcheck underlines — which otherwise stays stubbornly light on a dark page.

Nothing else is wired: no class on <html>, no JavaScript, no flash-of-wrong- theme script. Changing the OS appearance repaints the app live.

Dark-mode extensions

Some dark-mode extensions (Noir, for one) decide whether a site already has a dark theme by scanning its stylesheets for a prefers-color-scheme media query. They do not recognise light-dark(), so a palette built only from it looks light-only to them, and they repaint the app on top of its own dark theme: dark text on a dark card, inverted chart colours, surfaces dimmed twice.

So tokens.css carries one rule whose job is to be found:

@media (prefers-color-scheme: dark) {
	:root { color-scheme: light dark; }
}

It restates the :root declaration with the same selector, so it changes nothing and every pin still wins. A production build may emit a similar query on its own while polyfilling light-dark() for older targets, but the dev server and modern targets do not, which is why it is written out rather than left to the bundler. If you write your own palette, copy this rule too.

--hue drives the brand and the neutral ramp together, so the greys carry a whisper of the accent instead of reading as flat #808080. Change that one number and surfaces, borders, primary and ring all move — and so do chart slots 1 and 4, which are the brand hue at two lightnesses. Status colours (destructive, success, warning) and chart slots 2, 3 and 5 keep their own hues on purpose — a red that follows the brand hue is no longer a red.

The five --chart-* slots are a categorical ramp: a series takes the slot at its index and keeps it, so a filter that removes one series never repaints the others. Neighbouring slots are the pair a reader actually compares — two segments of a stack, two rows of a legend — so each adjacent pair is checked to clear ΔE 8 under simulated protanopia and deuteranopia and ΔE 15 under normal vision. That is also why slots 1 and 4 share the brand hue at different lightness: colour blindness collapses hue, and lightness is the separation that survives it. If you write your own palette, keep that property rather than picking five colours you like the look of, and pair the ramp with the legend and direct labels the chart components already render — colour is never the only channel. Past five series the ramp stops: group the tail into an "Other" rather than inventing a sixth hue.

Browser support: light-dark() needs Chrome 123 / Safari 17.5 / Firefox 120. Below that, write your palette as two blocks instead (see below); the components do not care how the tokens are produced.

Using it

Follow the system (the default, zero config)

@import '@l4st/driftwood/tokens.css';

Add this to app.html so the very first paint is right, before any CSS lands:

<meta name="color-scheme" content="light dark" />

Your own palette, still following the system

Copy the token list out of tokens.css, change the values, keep the shape. Define every token — components reference them unconditionally — and keep the prefers-color-scheme rule from above, or dark-mode extensions will repaint your dark theme.

:root {
	color-scheme: light dark;
	--hue: 35; /* amber */
	--background: light-dark(oklch(0.99 0.01 var(--hue)), oklch(0.16 0.01 var(--hue)));
	--card:       light-dark(oklch(1 0 0),                oklch(0.21 0.01 var(--hue)));
	/* … */
	--radius: 0.625rem;
}

Pin a theme, or offer a switcher

Pinning is one line per theme — flip color-scheme and every light-dark() token follows on its own. No palette duplication:

:root.dark,  :root[data-theme='dark']  { color-scheme: dark; }
:root.light, :root[data-theme='light'] { color-scheme: light; }

Both selectors already ship in tokens.css, so a class-based switcher such as mode-watcher works alongside the system default with nothing added: it writes .dark on <html>, the color-scheme flips, the tokens follow. Leave the class off and you are back on the OS.

A three-way switcher (light / dark / system) is the same idea: write data-theme="light" or "dark" to pin, and remove the attribute for system — do not write data-theme="system", because absence is what hands the decision back to color-scheme: light dark.

<script lang="ts">
	import { Select } from '@l4st/driftwood';

	let mode = $state<'system' | 'light' | 'dark'>('system');

	$effect(() => {
		const root = document.documentElement;
		if (mode === 'system') root.removeAttribute('data-theme');
		else root.setAttribute('data-theme', mode);
		localStorage.setItem('theme', mode);
	});
</script>

Older browsers, or a palette you already have

Two blocks work fine — the kit reads tokens, not how they were produced:

:root { --card: #fff; /* … */ }
@media (prefers-color-scheme: dark) {
	:root { --card: #1a1a1a; /* … */ }
}

Still declare color-scheme (light dark, or the pinned value) so browser furniture matches.

What is verified

pnpm probe drives a real headless Chromium with the OS theme emulated both ways. It also runs axe over the gallery and the overlays, which makes token contrast a build failure rather than a judgement call — if a token pair falls below 4.5:1 behind small text, the probes go red. It asserts:

  • color-scheme resolves to light dark under both OS settings;
  • the served stylesheets contain a prefers-color-scheme: dark media rule, found the way an extension finds it: by walking document.styleSheets;
  • surfaces, text and the body background all differ between the two;
  • opacity modifiers (bg-primary/90) resolve per theme — Tailwind wraps the token in color-mix(), which is the case light-dark() could plausibly have broken;
  • .light pins light under a dark OS, and data-theme="dark" pins dark under a light OS.

pnpm shots renders every component twice, with colorScheme emulated, which is where the light/dark pair on each component page comes from.

Checking your own palette

The components are only as accessible as the tokens behind them, and a palette is easy to get wrong in a way nobody sees until someone reports it. From a driftwood checkout:

pnpm contrast path/to/your/app.css

It reads the token block, splits light-dark() into both themes, and computes every pair the components actually render — body text, a Card description, muted text on bg-muted, each Badge's label on its status colour, the Alert tints, the sidebar pairs, and the hover blends — failing anything below 4.5:1. No browser, no gallery, no guessing which pairs matter.

The script ships with the package (node_modules/@l4st/driftwood/scripts/contrast.mjs), so an app can run it in its own CI.

Rules for anyone writing components

  • Semantic tokens only: bg-card, not bg-white; text-muted-foreground, not text-gray-500; border-input, not border-zinc-200.
  • No dark: variants in the kit. A dark: assumes a class-based strategy and silently breaks the app that follows the system or uses a media query.
  • New token? Add it to tokens.css (in light-dark() form), map it in theme.css, and say so in the release notes — every consuming app has to add it to its own palette.