Chapter 09
Theming
One attribute flips the whole world. Light and dark are not two stylesheets; they are one set of names that resolve to different values.
Built with
- data-theme
- localStorage
- Inline boot script
The mechanism
One attribute flips the whole world.
The root element carries data-theme, either light or dark. The semantic tokens are defined for light, and the dark block overrides only the ones that have to change. Components never ask which theme is active; they just read var(--background).
Proof
Side by side, same markup.
Each panel below is forced to a theme with its own attribute, so you can compare both at once whatever the page is set to.
light
Quiet by default.
The same markup, resolved by a different attribute.
dark
Quiet by default.
The same markup, resolved by a different attribute.
The delta
What dark changes.
24 tokens differ in dark. Everything else, including the radii, fonts, spacing and every primitive, is shared. Each pair below is live.
--background
lightdark--background-elevated
lightdark--background-surface
lightdark--foreground
lightdark--foreground-muted
lightdark--foreground-subtle
lightdark--primary
lightdark--primary-light
lightdark--primary-dark
lightdark--secondary
lightdark--accent-warm
lightdark--accent-coral
lightdark--border
lightdark--border-subtle
lightdark--success
lightdark--error
lightdark--badge-date-fg
lightdark--badge-date-bg
lightdark--badge-date-border
lightdark--chat-header-bg
lightdark--chat-header-fg
lightdark--chat-glass-bg
lightdark--heatmap-l0
lightdark--heatmap-l4
lightdark
[data-theme="dark"] { --background: var(--ink-950); --background-elevated: var(--ink-900); --background-surface: var(--ink-800); --foreground: var(--cream-50); --foreground-muted: var(--grey-dark-muted); --foreground-subtle: var(--grey-dark-subtle); --primary: var(--violet-400); --primary-light: var(--violet-300); --primary-dark: var(--violet-550); --secondary: var(--cream-300); --accent-warm: var(--peach-300); --accent-coral: var(--peach-400); --border: rgba(250, 227, 198, 0.12); --border-subtle: rgba(250, 227, 198, 0.07); --success: var(--green-400); --error: var(--red-400); --badge-date-fg: var(--peach-300); --badge-date-bg: rgba(245, 201, 138, 0.12); --badge-date-border: rgba(245, 201, 138, 0.28); --chat-header-bg: var(--violet-200); --chat-header-fg: var(--violet-950); --chat-glass-bg: color-mix(in srgb, var(--ink-900) 68%, transparent); --heatmap-l0: rgba(244, 242, 248, 0.05); --heatmap-l4: var(--primary-light);}Polish
No flash on load.
A tiny script in the document head sets the attribute before the first paint, using the saved choice and defaulting to dark. Storage can be blocked, so it is wrapped in a try, and the page falls back to dark rather than failing.
// Runs in <head>, before first paint, so the page never flashes the wrong theme.try { var t = localStorage.getItem('theme'); document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');} catch (e) { document.documentElement.setAttribute('data-theme', 'dark');}Growing
Adding a theme.
A third theme is just another block of overrides. Because components only know token names, nothing else in the codebase needs to learn that it exists.
/* 1. Override only the semantic tokens that should differ */[data-theme="dusk"] { --background: var(--violet-950); --foreground: var(--cream-50); --primary: var(--violet-300);} /* 2. Nothing else changes. Components already read var(--token). */