Core concept 03
Dark Mode Done Right
Dark mode is a token concern, not a component concern. Centralise theme overrides in one place — every component updates automatically, including ones you haven't written yet.
The wrong way
The most common dark mode implementation scatters overrides across every component file. Each new component requires its own [data-theme="dark"] block. The theming concern is distributed everywhere.
How it works
The entire dark mode implementation lives in css/tokens.css, inside @layer tokens. When the data-theme="dark" attribute is set on <html>, the semantic token values change. Every component that references those tokens updates automatically.
@layer tokens { /* Tier 2 — Light (default) */ :root, [data-theme="light"] { --surface-page: var(--color-gray-50); --surface-raised: var(--color-white); --text-primary: var(--color-gray-950); --accent-default: var(--color-amber-500); } /* Tier 2 — Dark overrides */ [data-theme="dark"] { --surface-page: var(--color-black); --surface-raised: var(--color-gray-900); --text-primary: var(--color-gray-50); --accent-default: var(--color-amber-400); } /* OS preference as fallback — only when no manual override is set */ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --surface-page: var(--color-black); --surface-raised: var(--color-gray-900); --text-primary: var(--color-gray-50); } } }
Components are mode-agnostic by default
Because components only reference semantic tokens, every new component you write automatically supports both light and dark mode. There is no dark mode work required at the component level — ever.
Preventing flash of wrong theme
If the theme is applied via JavaScript after the page renders, users see a brief flash of the wrong theme. The fix is a small inline script in <head> that runs synchronously before any content renders.
This script must be inline
An external script file would introduce a round-trip request and still flash. The script must be inline in <head>, and it must run before the stylesheet is applied. Do not move it, defer it, or make it async.
<!-- Anti-flash: sets data-theme before paint --> <script> (function () { var stored = null; try { stored = localStorage.getItem('theme-preference'); } catch (e) {} var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', theme); })(); </script>
Priority logic
The script resolves the active theme in this order:
- Check
localStoragefor an explicit user preference - Fall back to
prefers-color-scheme(OS setting) - Default to
lightif neither is available
The toggle
The theme toggle button is included in the nav on every page. The JavaScript in js/main.js handles reading and writing the preference.
// Toggle between dark and light, persist to localStorage function toggle() { const current = html.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; html.setAttribute('data-theme', next); localStorage.setItem('theme-preference', next); }
This page is the demo — toggle the theme in the top-right corner
Every surface, border, and text colour on this page responds to the toggle. The component CSS has not changed — only the semantic token values swap in tokens.css.
Rules to follow
| Rule | Why |
|---|---|
| Never use hardcoded colours in components | They won't respond to theme changes |
| Never add dark mode overrides in component files | Dark mode lives in tokens.css only |
| Always reference semantic tokens, not primitives | Primitives don't change between themes |
Keep the anti-flash script inline in <head> |
External scripts cause flash |
Use :root:not([data-theme="light"]) for OS media query |
Prevents media query from overriding explicit user choice |