Dark mode is easy until it’s not. The first time your app flashes white at 2 a.m. on an OLED phone, you’ll feel it in your soul. The second time is worse: someone files a bug because your “dark mode” makes charts unreadable, resets every refresh, and breaks printing. If you’re shipping production systems, theme bugs aren’t cosmetic. They’re trust bugs.
The goal here is a pattern that behaves like a well-run service: it respects the platform, gives users a clear override, persists predictably, avoids flicker, and stays testable. You’ll implement prefers-color-scheme correctly, add a manual toggle, and keep the whole thing maintainable when your design system grows teeth.
What “good” looks like in production
A theme system isn’t a fashion show. It’s a contract between browser, OS, your CSS, your JavaScript, and the user’s intent.
Non-negotiables
- Default: follow
prefers-color-scheme. - Override: a manual toggle that wins over system preference.
- Persistence: remember the override across sessions, without getting stuck in the past.
- No flash: first paint should already be the intended theme.
- Accessible: contrast passes, focus rings visible, and the toggle is keyboard/screen-reader friendly.
- Composable: works inside a design system and across micro-frontends without three competing “truths.”
The most common failure mode is treating “dark mode” as a CSS afterthought. It becomes a pile of overrides, plus a little script that flips a class after load. That’s not a feature; that’s a race condition with a user interface.
One quote to keep you honest. Hope is not a strategy.
— Gene Kranz. (Yes, it’s been reused to death. It’s also still correct.)
Short joke #1: If your theme toggle needs a spinner, you didn’t build a toggle—you built a distributed system.
Facts and history worth knowing
Dark mode didn’t arrive as a single feature; it accreted from hardware constraints, accessibility needs, and power budgets. A few concrete points that influence today’s implementation decisions:
- Early “dark UIs” were about phosphor and glare: terminal UIs and early monitors pushed light text on dark backgrounds partly to reduce perceived glare in dim environments.
- OLED changed the economics: on OLED, black pixels can draw substantially less power than white ones. On LCD, the backlight dominates, so the power savings can be negligible.
prefers-color-schemeis a relatively recent web platform primitive: it became widely usable only after modern browsers aligned on media query support; before that, every site invented its own toggle and persistence logic.- Design systems made the problem harder: once you have tokens, components, and multiple products, the “just override a few colors” approach stops scaling.
- High-contrast modes have existed longer than dark mode hype: OS-level forced colors and contrast settings were already a thing; many “dark mode” rollouts accidentally broke them.
- Printing is a hidden stakeholder: a dark background may be fine on screen, disastrous on paper or PDF exports unless you handle print styles explicitly.
- Charts and data viz are frequent casualties: color ramps, grid lines, and annotation contrast require separate tuning; you can’t simply invert the page.
- Enterprise apps love iframes: embedded contexts (webviews, iframes) complicate theme propagation and persistence.
The core contract: system preference + user override
Here’s the model that works: treat theme as a three-state preference, not a boolean.
- System (default): follow
prefers-color-scheme. - Light (override): user forces light.
- Dark (override): user forces dark.
If you store just “dark=true/false” you inevitably break someone: the user who toggled once months ago, then changed OS preference later, and now wonders why your app is out of sync. Three-state fixes that. Store theme=system|light|dark, and compute the effective theme at runtime.
Where to store it
You have two common persistence locations:
localStorage: simplest, client-side, per-browser profile. Fine for most sites.- Cookie: needed when you want SSR to emit the correct theme on first response without waiting for JS.
Pick one. Don’t write to both unless you enjoy debugging subtle precedence bugs at 3 a.m.
How to apply it
Use a single authoritative attribute on <html> (or <body>) like data-theme="dark". Avoid scattering theme classes across component roots. Every extra toggle point is a potential split-brain.
CSS architecture that won’t collapse later
Do not theme by rewriting component styles one-by-one. You’ll die of papercuts. Theme via tokens: CSS variables that represent intent (surface, text, border, accent), not raw colors.
Use semantic tokens, not “blue-500” tokens
When a product manager asks for “a slightly warmer background in dark mode,” you want to change one variable, not hunt for twenty hex values. A practical token set looks like this:
--color-bg,--color-surface--color-text,--color-muted--color-border--color-link,--color-link-visited--color-focus--shadow-elevation-1(yes, shadow tokens too)
Baseline CSS pattern
Set defaults in :root and override per theme using an attribute selector. Then optionally layer in system preference when the user is in system mode.
cr0x@server:~$ cat theme.css
:root {
color-scheme: light dark;
--color-bg: #ffffff;
--color-surface: #f6f7f9;
--color-text: #111827;
--color-muted: #4b5563;
--color-border: #d1d5db;
--color-focus: #2563eb;
}
:root[data-theme="dark"] {
--color-bg: #0b1220;
--color-surface: #0f172a;
--color-text: #e5e7eb;
--color-muted: #94a3b8;
--color-border: #243041;
--color-focus: #60a5fa;
}
@media (prefers-color-scheme: dark) {
:root[data-theme="system"] {
--color-bg: #0b1220;
--color-surface: #0f172a;
--color-text: #e5e7eb;
--color-muted: #94a3b8;
--color-border: #243041;
--color-focus: #60a5fa;
}
}
html, body {
background: var(--color-bg);
color: var(--color-text);
}
What this gets you: a single place to define theme values, and a clean, testable selection mechanism. Also, note color-scheme: light dark;. That tells the browser you support both, so form controls and scrollbars can render appropriately in many environments.
Don’t theme by inverting
CSS filters like filter: invert(1) are a party trick. They break images, destroy brand colors, and make screenshots look like evidence in a paranormal investigation.
Handle images and icons explicitly
For icons, prefer SVG with fill="currentColor" so they inherit --color-text or a token-defined color. For raster images, decide: do they stay the same, or do you have a dark variant? If it’s product-critical imagery (maps, diagrams), you probably need variants.
The toggle: state machine, not vibes
Your JavaScript has three jobs:
- Determine the stored preference (
system,light,dark). - Set
data-themebefore first paint when possible. - Expose a toggle UI that changes the stored preference and updates the DOM.
A minimal, reliable implementation
Keep the logic small. Make it boring. The cleverness belongs in your product, not in theme plumbing.
cr0x@server:~$ cat theme.js
(function () {
const STORAGE_KEY = "theme-preference"; // "system" | "light" | "dark"
const root = document.documentElement;
function readPreference() {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch (e) {}
return "system";
}
function writePreference(value) {
try {
localStorage.setItem(STORAGE_KEY, value);
} catch (e) {}
}
function applyPreference(value) {
root.setAttribute("data-theme", value);
}
function cyclePreference(current) {
// Opinionated: cycle system -> light -> dark -> system
if (current === "system") return "light";
if (current === "light") return "dark";
return "system";
}
// Early apply on load
const initial = readPreference();
applyPreference(initial);
// Export small API for the button
window.theme = {
get: readPreference,
set: (v) => { writePreference(v); applyPreference(v); },
cycle: () => {
const next = cyclePreference(readPreference());
writePreference(next);
applyPreference(next);
return next;
}
};
})();
This is intentionally unexciting. That’s a compliment. The toggle button can call window.theme.cycle() and update its label.
Listen to system changes (but only when in system mode)
If the user selected system, they mean it. If they selected dark, they really mean it. So only respond to OS theme changes when the stored preference is system.
cr0x@server:~$ cat theme-system-listener.js
(function () {
const media = window.matchMedia("(prefers-color-scheme: dark)");
function onChange() {
const pref = window.theme && window.theme.get ? window.theme.get() : "system";
if (pref === "system") {
document.documentElement.setAttribute("data-theme", "system");
}
}
if (media.addEventListener) media.addEventListener("change", onChange);
else if (media.addListener) media.addListener(onChange);
})();
Note what it doesn’t do: it doesn’t rewrite localStorage. System preference changes should not overwrite user intent. Your app should simply re-evaluate effective colors via the media query in CSS.
Killing the flash (FOUC/FOWT) without hacks
The flash of wrong theme is usually self-inflicted: you load CSS that depends on a data-theme attribute, but you only set that attribute after the page renders. Users see the default theme for a split second. On a fast desktop, it’s a flicker. On a mid-range phone with a cold cache, it’s a strobe light.
Best practice: inline a tiny “theme bootstrap” script in the head
Yes, inline. Yes, before your CSS if you can. This isn’t “JS bloat”; it’s correctness. Keep it tiny and synchronous.
cr0x@server:~$ cat theme-bootstrap-inline.js
(function () {
try {
var v = localStorage.getItem("theme-preference");
if (v !== "light" && v !== "dark" && v !== "system") v = "system";
document.documentElement.setAttribute("data-theme", v);
} catch (e) {
document.documentElement.setAttribute("data-theme", "system");
}
})();
Then your CSS media query for system takes over. The browser can compute styles before first paint.
What about CSP?
If your CSP disallows inline scripts, you have a trade-off: accept flicker, allow a small inline script via nonce/hash, or do server-side theme selection via cookies. In a corporate environment, CSP often wins; plan for it early rather than discovering it after security review.
One more thing: set color-scheme
Even if you nail your background and text, native controls can lag behind. Declaring color-scheme: light dark; in :root helps browsers render form controls in a matching style. It’s not perfect everywhere, but it’s cheap and usually correct.
SSR, hydration, and why your first paint matters
Client-only apps can afford a little indecision. SSR apps can’t. With SSR, users see HTML and CSS before your bundle loads. If your server ships light theme HTML but the client decides dark theme after hydration, you get a visible flip and sometimes layout changes (fonts, borders, images). It looks like the page is rebooting.
Server-side decision options
- Cookie-based override: If a user has set
theme=dark, the server can render dark immediately. - System preference: The server can’t reliably know
prefers-color-schemefrom the HTTP request alone. Some newer client hints exist in the ecosystem, but treat them as optional, not required. - Hybrid: Default server render uses
data-theme="system". If cookie indicates override, setdata-themeaccordingly.
The hybrid approach works well: let CSS and media queries handle system preference; let cookies handle explicit overrides. If you do this, keep cookie scope consistent across subdomains that share the same UI; nothing says “cohesive product suite” like every subdomain re-deciding your eyeballs.
Hydration mismatch: the classic footgun
If your client-side framework renders theme-specific markup (e.g., different SVGs, different component trees), and the server rendered the other theme, you can trigger hydration warnings or full re-render. The fix: keep markup identical across themes whenever possible and vary presentation via CSS variables.
Accessibility: contrast, focus, and reduced motion
Dark mode can be easier on some eyes, worse on others. There’s no universal “comfort.” Your job is to avoid making the UI illegible or physically unpleasant.
Contrast is not optional
In dark themes, designers often pick muted gray text on near-black backgrounds. It looks “premium.” It also fails contrast and turns reading into work. If you need a muted tone, use it sparingly (secondary labels), not for primary content.
Focus rings must survive the theme
Many teams remove outlines for aesthetic reasons, then forget to add them back. A dark theme with invisible focus is functionally broken for keyboard users. Use a token like --color-focus and keep it bright enough for both themes.
Honor reduced motion
Theme transitions (fade between themes) can look slick. They can also trigger motion sensitivity when overdone. Respect prefers-reduced-motion and disable or shorten transitions.
cr0x@server:~$ cat motion.css
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
Short joke #2: If you animate a theme switch for 600ms, your users will have time to make tea and reconsider your product.
Observability: measure adoption and breakage
Dark mode is a product feature. That means you should measure it. Not obsessively. Just enough to catch regressions and understand whether the toggle is doing what you think.
What to log (and what not to)
- Log: theme preference state transitions (system → dark, dark → system).
- Log: effective theme on first paint (if you can instrument it), to detect flicker incidents.
- Do not log: “user has dark OS preference” as a user attribute tied to identity without considering privacy and policy. It’s surprisingly fingerprinty when combined with other signals.
A practical approach: emit an analytics event on user interaction with the toggle. Separately, record client-side metrics for “first paint theme match” sampled at a low rate. If you see mismatch rates spike after a release, you know exactly where to look: bootstrap script, SSR template, or CSS ordering.
Three corporate mini-stories from the theme trenches
1) The incident caused by a wrong assumption
A company shipped a new header component as part of a design system refresh. It looked great in light mode. In dark mode, it looked mostly fine too—until you opened a dropdown menu. The menu background was dark, but the menu text was still dark-gray. It wasn’t just low contrast; it was invisible.
The wrong assumption was subtle: the team believed “dark mode” was just swapping background and text colors at the page level. The dropdown component, however, had hard-coded colors in a nested stylesheet that never used tokens. The component library “supported theming” in the README, but only at the shell level.
Support tickets landed first. Then internal users started taking screenshots and pasting them into chat with annotations like “is this a feature flag for blindness.” An incident was declared because the dropdown controlled an account security setting. People couldn’t log out or manage sessions in dark mode, and the app defaulted to dark for a big chunk of users.
The fix wasn’t heroic. They introduced semantic tokens and required every component style to use them, with lint rules to reject raw color values except in token definitions. Then they added visual regression tests for key flows in both themes. The lesson: assumptions about “global theming” collapse the moment a component ships with hard-coded colors.
2) The optimization that backfired
Another team wanted to eliminate the tiny inline theme bootstrap script because their performance budget was tight and their security team was cranky about inline JS. So they moved theme initialization into the main bundle and used defer. They also added a neat fade transition between themes to make it “feel premium.”
In the lab, it looked fine. On real devices, it was chaos. Users on slow networks saw a white flash, then a fade to dark, then a second flash when hydration replaced the server-rendered markup. The transition amplified the problem: instead of a brief blink, it became a noticeable animation that called attention to itself.
It got worse in embedded webviews inside a native app. The webview sometimes delayed the JS bundle longer than expected, so the initial theme persisted for seconds. People thought the toggle “didn’t work,” because it did work—eventually. Just not in any timeframe humans consider reasonable.
They rolled back the transition, reintroduced a CSP-hashed inline script, and shipped a cookie-based override for SSR. Performance improved in practice because users stopped triggering re-renders and layout shifts caused by theme flips. The lesson: optimizing for the wrong metric (bundle purity) can make the user-visible performance worse.
3) The boring but correct practice that saved the day
A third org did something that felt painfully unglamorous: they created a “theme contract” doc and a small conformance test suite for components. Every component had to render correctly under data-theme="light", "dark", and "system" with both prefers-color-scheme settings in the test runner.
They also standardized token naming and prohibited ad-hoc variables in component CSS. Want a new shade? Add a token, justify it, and wire it through both themes. It slowed down the first few PRs. People complained. Then the complaints stopped because the rules were predictable.
One quarter later, a large rebrand landed: new accent color, new background tint, updated shadows. Teams expected breakage. Instead, they changed token values, ran tests, and shipped. The “boring” discipline meant they didn’t have to hunt through hundreds of CSS files for hex values like archaeologists excavating a bad decision.
The lesson: the best theme system is mostly process. The code is the easy part.
Practical tasks (with commands) to debug and decide
These are tasks you can run today on a dev machine or CI runner. Each includes: a command, what output means, and what decision you make from it. The goal is operational: reduce mystery, reduce guesswork.
Task 1: Verify your built CSS actually contains theme selectors
cr0x@server:~$ rg -n 'data-theme="dark"|prefers-color-scheme' dist/assets/*.css
dist/assets/app.9c31.css:12::root[data-theme="dark"]{--color-bg:#0b1220;...}
dist/assets/app.9c31.css:38:@media (prefers-color-scheme: dark){:root[data-theme="system"]{...}}
Meaning: You can see both the explicit override and the system-mode media query in the shipped artifact.
Decision: If these strings aren’t present, your build pipeline stripped or never included theme CSS. Fix import order or bundler config before debugging UI behavior.
Task 2: Detect hard-coded hex colors in component CSS (token bypass)
cr0x@server:~$ rg -n --glob='**/*.css' '#[0-9a-fA-F]{3,8}\b' src/
src/components/dropdown.css:44:color: #111827;
src/components/dropdown.css:51:background: #ffffff;
Meaning: Components are bypassing tokens; they’ll probably break in one theme.
Decision: Replace with semantic variables (e.g., var(--color-text), var(--color-surface)) and allow raw hex only in token files.
Task 3: Confirm the HTML root has the expected attribute at rest
cr0x@server:~$ node -e "const {JSDOM}=require('jsdom'); const html='<!doctype html><html data-theme=\"dark\"></html>'; const dom=new JSDOM(html); console.log(dom.window.document.documentElement.getAttribute('data-theme'));"
dark
Meaning: Your templates/SSR can set the attribute.
Decision: If SSR never sets data-theme, you must rely on client bootstrap and accept potential flicker—or implement cookie-based selection.
Task 4: Validate localStorage persistence behavior in a headless browser run
cr0x@server:~$ node -e "console.log('Simulate: read=system when empty, store=dark');"
Simulate: read=system when empty, store=dark
Meaning: This is a placeholder sanity step for CI scripting: ensure your code paths handle missing/invalid values and don’t crash.
Decision: If you have exceptions thrown by storage access in private mode or hardened environments, add try/catch and default to system.
Task 5: Check CSP headers for inline-script feasibility
cr0x@server:~$ curl -sI http://localhost:3000 | rg -i 'content-security-policy'
content-security-policy: default-src 'self'; script-src 'self'
Meaning: Inline scripts are blocked (no 'unsafe-inline', no nonce, no hash).
Decision: Either add a nonce/hash for the tiny bootstrap, or use cookie-based SSR theme selection to avoid flicker.
Task 6: Confirm the server sets a theme cookie when user chooses an override
cr0x@server:~$ curl -sI http://localhost:3000/set-theme?value=dark | rg -i 'set-cookie'
set-cookie: theme=dark; Path=/; SameSite=Lax
Meaning: The server can persist an override in a way SSR can read.
Decision: If you don’t see Set-Cookie, SSR can’t know about overrides; you’ll need the inline bootstrap path.
Task 7: Validate that the cookie is sent back on the next request
cr0x@server:~$ curl -sI --cookie "theme=dark" http://localhost:3000 | rg -i 'data-theme|set-cookie'
set-cookie: session=...; Path=/; HttpOnly; SameSite=Lax
Meaning: Cookie is present and request succeeded. (You won’t see data-theme in headers; you’ll verify HTML next.)
Decision: Proceed to fetch HTML and inspect whether SSR used the cookie.
Task 8: Inspect SSR HTML for theme attribute correctness
cr0x@server:~$ curl -s --cookie "theme=dark" http://localhost:3000 | head -n 5
Meaning: SSR is rendering the correct theme immediately.
Decision: If SSR still emits system, your server isn’t reading the cookie (or middleware order is wrong).
Task 9: Confirm your CSS declares color-scheme for native UI alignment
cr0x@server:~$ rg -n 'color-scheme:\s*light\s+dark' src/**/*.css
src/styles/theme.css:2: color-scheme: light dark;
Meaning: Browsers have a hint to theme native widgets.
Decision: If missing, add it; then re-test form controls and scrollbars across platforms.
Task 10: Catch accidental theme overrides from third-party CSS
cr0x@server:~$ rg -n 'background:\s*#fff|color:\s*#000' node_modules/some-widget/dist/widget.css
node_modules/some-widget/dist/widget.css:88:background: #fff;
node_modules/some-widget/dist/widget.css:89:color: #000;
Meaning: A vendor stylesheet hard-codes colors and will look wrong in dark mode.
Decision: Wrap it (shadow DOM or container with overrides), patch it via CSS variables if supported, or replace the widget. Don’t pretend it’ll fix itself.
Task 11: Verify that print styles don’t dump a black page onto paper
cr0x@server:~$ rg -n '@media\s+print' src/styles/*.css
src/styles/print.css:1:@media print {
Meaning: You have explicit print handling.
Decision: If absent, add a print stylesheet that forces light background and dark text, regardless of theme, unless your product explicitly requires dark print.
Task 12: Check that reduced motion is respected for theme transitions
cr0x@server:~$ rg -n 'prefers-reduced-motion' src/styles/**/*.css
src/styles/motion.css:1:@media (prefers-reduced-motion: reduce) {
Meaning: You’ve at least considered motion sensitivity.
Decision: If missing and you have transitions on colors/backgrounds, implement the reduced-motion guard.
Task 13: Sanity-check that your toggle is reachable and labeled (static check)
cr0x@server:~$ rg -n 'aria-label="Theme"|aria-pressed|role="switch"' src/
src/components/ThemeToggle.tsx:18:<button aria-label="Theme" aria-pressed={...}>
Meaning: Your toggle likely exposes state to assistive tech.
Decision: If there’s no labeling or state attribute, fix it before shipping; accessibility regressions aren’t “nice-to-have” bugs.
Task 14: Make sure your build didn’t reorder CSS in a way that breaks precedence
cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 214K Dec 29 10:22 dist/assets/app.9c31.css
-rw-r--r-- 1 cr0x cr0x 48K Dec 29 10:22 dist/assets/vendor.1a02.css
Meaning: You have multiple CSS files; order matters.
Decision: Ensure token definitions load before component CSS, and theme overrides load after defaults. If vendor CSS loads last, it may stomp your colors.
Fast diagnosis playbook
When dark mode is “broken,” don’t start by rewriting CSS. Start by finding where truth diverges: stored preference, DOM attribute, computed tokens, or component styles.
First: determine the selected preference and effective theme
- Inspect
document.documentElement.dataset.themein DevTools console. - Check storage/cookie value for
theme-preference(or your chosen key). - Check OS/browser preference: does
prefers-color-scheme: darkmatch what you think it does?
If preference is wrong: your toggle/persistence logic is broken. Fix JS and storage first.
Second: verify first paint behavior (the flicker hunt)
- Hard refresh with cache disabled. Watch for flash.
- Check whether
data-themeis present in SSR HTML or set early by an inline script. - Check CSP: if inline scripts are blocked and SSR doesn’t set the theme, you’ll get flash.
If first paint is wrong: move theme selection earlier (SSR cookie or inline bootstrap).
Third: isolate CSS token failures vs component hard-coding
- Inspect an unreadable component and look at computed styles: are values coming from
var(--...)or literal colors? - If literal colors: component bypassed tokens or vendor CSS stomped it.
- If tokens are used but wrong: token values aren’t being overridden for the active theme.
If tokens don’t override: fix selector specificity/order (:root[data-theme="dark"] not being applied), and verify CSS load order.
Common mistakes: symptoms → root cause → fix
1) Symptom: dark mode works after toggle, but resets on refresh
Root cause: state is stored in memory (framework state), not persisted; or storage write is failing (private mode, blocked storage, exceptions).
Fix: persist theme-preference in localStorage with try/catch; default to system when storage is unavailable.
2) Symptom: page flashes light then switches to dark
Root cause: data-theme is applied after first paint (bundle loads late), or SSR rendered a different theme than client computed.
Fix: inline a minimal bootstrap script (nonce/hash if needed), or render override via cookie in SSR. Keep markup theme-neutral; vary presentation via tokens.
3) Symptom: only some components switch theme
Root cause: components hard-code colors or use their own theme mechanism; multiple theme roots exist.
Fix: enforce token usage; make <html data-theme> the single source of truth; remove competing classes.
4) Symptom: forms look wrong (white inputs on dark background)
Root cause: missing color-scheme declaration; native controls not informed; component styles partially overridden.
Fix: set color-scheme: light dark; in :root; explicitly style form controls with tokens where needed.
5) Symptom: charts are unreadable in dark mode
Root cause: data visualization palette tuned for light backgrounds; gridlines/axes use low contrast; canvas/SVG text not themed.
Fix: define chart-specific tokens (axis, grid, series palette) and switch them per theme; test with real datasets, not demo data.
6) Symptom: system preference changes don’t reflect when in “system”
Root cause: CSS media query is absent or overridden; app stored a binary dark/light and never re-evaluates.
Fix: store three-state preference; use @media (prefers-color-scheme: dark) for data-theme="system" overrides.
7) Symptom: toggle is inaccessible or confusing
Root cause: icon-only control without label; state not conveyed (aria-pressed missing); “system” state not represented.
Fix: use a labeled button or switch; include “System / Light / Dark” choices or a cycle with a clear tooltip and accessible name.
8) Symptom: print/PDF output is black pages or wasted ink
Root cause: dark theme styles apply to print; no print overrides.
Fix: add print CSS that forces light backgrounds and dark text, and disables decorative backgrounds.
Checklists / step-by-step plan
Step-by-step implementation plan (the pattern that holds up)
- Define the preference model:
system|light|dark. Write it down; make it part of the product contract. - Pick one persistence path:
localStoragefor client-only; cookie if SSR needs first paint correctness for overrides. - Implement one theme root:
<html data-theme="system">. Everything else references it. - Build semantic tokens: background, surface, text, muted, border, focus, link, shadows.
- Implement CSS overrides:
:root[data-theme="dark"]and@media (prefers-color-scheme: dark) :root[data-theme="system"]. - Set
color-scheme:color-scheme: light dark;in:root. - Inline bootstrap (or SSR cookie): ensure
data-themeis present before first paint. - Build the toggle UI: accessible label, state exposure (
aria-pressedorrole="switch"), clear “system” handling. - Audit components for raw colors: remove or gate them behind tokens.
- Test critical flows in both themes: auth, settings, tables, charts, modals, toasts, error pages.
- Handle print: force print-friendly palette.
- Add observability: track toggle use and first-paint mismatch rate (sampled).
Release checklist (what you verify before shipping)
- Hard refresh on slow network: no flash of wrong theme.
- Theme persists across refresh and new tab.
- System mode follows OS changes without overwriting stored preference.
- Forms, modals, dropdowns readable in both themes.
- Focus rings visible and consistent.
- Charts and data tables pass a “squint test” in dark mode.
- Print output is sane.
- Third-party widgets don’t hard-break the theme.
FAQ
Should the toggle be a two-state switch or three-state (system/light/dark)?
Three-state wins in the real world. Two-state forces you to guess what “off” means and makes users sticky to an old choice when OS preference changes.
Is prefers-color-scheme enough without a toggle?
No. Some users want the opposite of their system preference for a specific site (work vs personal devices, bright offices, glare). Give them an override.
Where should the theme attribute live: <html> or <body>?
<html> is the cleanest root and works nicely with :root token declarations. Pick one and be consistent.
Why not just toggle a .dark class?
You can, but attribute selectors are easier to extend (system/light/dark) and reduce collision with unrelated classes. The big win is consistency, not syntax.
Do I need to listen for system theme changes in JavaScript?
Not strictly, if your CSS uses @media (prefers-color-scheme) for data-theme="system". A listener can help update UI labels/icons, but don’t rewrite stored preference.
How do I avoid hydration mismatch in SSR frameworks?
Keep DOM structure the same across themes. Use CSS variables for styling differences. If you must change markup, decide theme server-side via cookie for overrides.
How should I name tokens?
Name by intent: --color-surface, not --gray-950. Your future self will thank you when branding changes. Your present self will also thank you, just quietly.
Does dark mode always save battery?
Not always. On OLED, often yes. On LCD, the backlight stays on, so savings are smaller. Ship dark mode for usability and preference first; treat power savings as a nice side effect.
What about high contrast / forced colors modes?
Don’t fight them. Avoid hard-coded background images behind text, keep focus styles visible, and test forced-colors behavior. If your design breaks there, it’s not “edge-case”; it’s accessibility debt.
Should we animate theme transitions?
Usually: no, or keep it extremely subtle and disabled under prefers-reduced-motion. Theme is a state, not a nightclub.
Conclusion: next steps you can ship
Dark mode done well is a reliability problem disguised as a design choice. Respect system preference, offer an override, persist predictably, and make first paint correct. Use tokens so the system scales, and add just enough observability to catch regressions before your users do.
Practical next steps
- Implement the three-state preference and set
data-themeon<html>. - Move all theme values into semantic CSS variables and remove component hard-coded colors.
- Add either an inline bootstrap script (CSP-safe via nonce/hash) or SSR cookie selection for overrides to kill flicker.
- Run the grep-based audits (raw hex, missing
color-scheme, vendor CSS stomping) and fix the worst offenders first. - Test critical flows in both themes and in print, then lock it in with automated checks.