Dark Mode & Theming
Dark mode is a good test of whether an interface was built systematically. If it was, adding a theme is a second set of token values and roughly a day of refinement. If it wasn't — if components reference colors directly — it's a multi-week audit of every component, and the result needs re-auditing every time someone ships a feature.
The architecture that makes it cheap is the semantic token layer: components reference --color-bg-raised, never --gray-50, so a theme is a remapping rather than a rewrite. Everything else in this topic is either a consequence of that (multi-brand theming works the same way) or a set of implementation details that are individually small and collectively the difference between a polished implementation and an annoying one — chiefly the flash of wrong theme, which is the single most noticeable failure and entirely avoidable.
Worth stating plainly: dark mode is not an inversion. It's a distinct design with its own contrast, chroma, and elevation rules.
TL;DR
- Components reference semantic tokens; a theme swaps what those tokens point to. No other approach scales.
data-themeon<html>plus CSS custom properties is the standard implementation.- Prevent the flash of wrong theme with a tiny blocking inline script before first paint.
- Support three states: system, light, dark — with system as the default.
- Set
color-schemeso form controls, scrollbars, and the browser UI match. - Dark mode needs less chroma, lighter accent colors, and no pure black or white.
- Elevation inverts: higher surfaces get lighter in dark mode, since shadows are invisible.
- Re-verify contrast in every theme — a passing light ratio says nothing about dark.
Quick Example
A complete, flash-free implementation:
Two details there are easy to get wrong and both are visible to users: storing the choice (system) rather than the resolved value (dark), so the OS continues to be followed; and the blocking script, without which every dark-mode user sees a white flash on every page load.
Core Concepts
The flash of wrong theme
The script must be inline and blocking in <head>, before stylesheets. Bundling it, deferring it, or putting it in a component means it runs after first paint and the flash returns. Frameworks provide helpers for exactly this: next-themes for Next.js, and equivalents elsewhere — all of them work by injecting this same script.
For a server-rendered app, store the preference in a cookie as well as localStorage so the server can render the correct data-theme attribute directly, eliminating even the script's work.
color-scheme
This is a small declaration with a disproportionate effect: it tells the browser to render native UI — form controls, scrollbars, the default page background before CSS loads, date pickers — in the matching scheme. Without it you get a dark page with bright white scrollbars and light form inputs, which looks broken in a way that's hard to diagnose because nothing in your CSS is wrong.
Dark mode is not an inversion
Getting elevation backwards is the most common visual mistake: a dark theme where modals are darker than the page reads as a hole rather than as something in front.
Multi-brand theming
The same architecture handles brands, and this is the payoff of the token tiers:
Because custom properties cascade, a theme can also be scoped to a subtree — an embedded widget, a preview pane, or a marketing section that stays light inside a dark app:
This is impossible with preprocessor variables and is a real reason to implement theming with custom properties rather than SCSS.
Handling images and media
Inline SVG is the best case — it can use currentColor or your tokens directly and needs no variant at all.
Best Practices
Build the token layer before building the theme
If components reference primitives, no amount of theming CSS will fix it. Auditing and converting to semantic tokens is the actual work; adding the dark values afterward is quick. See Design Tokens.
Default to the system preference
prefers-color-scheme reflects a choice the user already made at the OS level. Respect it, and offer an override for people who want your app to differ from their system — but don't make them configure it just to get what they already asked for.
Store the choice, not the resolved value
Persisting dark when the user selected system means they stop tracking their OS setting, and their app is suddenly light at night. Store system and resolve at render.
Verify contrast in every theme
A pair that passes at 4.8:1 in light mode can fall to 3.1:1 in dark. Run the contrast checks over each theme's token set in CI, not over the light theme only.
Respect prefers-contrast and forced-colors too
Dark mode is one axis of user preference among several, and forced-colors mode in particular breaks custom components that rely on background colors for meaning.
Test both themes in visual regression
A component library needs screenshots in both themes. Dark mode regressions are otherwise found by users, because most development happens in one theme and nobody notices the other drifting.
Don't animate the theme transition
The staggered result reads as a glitch rather than a transition. If you want a transition, use the View Transitions API on the root, or accept an instant switch — which is what most well-implemented apps do.
Common Mistakes
No blocking script — the flash
Components referencing primitives
Omitting color-scheme
Pure black and pure white
Same accent color in both themes
Elevation the wrong way round
FAQ
prefers-color-scheme or a manual toggle?
Both. Default to the system preference, and offer a three-state control (System / Light / Dark) for users who want to override it. System-only frustrates people who prefer a different setting for your app; toggle-only ignores a preference they've already expressed. The three-state pattern is what every well-implemented app converges on.
data-theme attribute or a CSS class?
Functionally equivalent; data-theme is slightly cleaner because it's a single-valued attribute rather than a class that could coexist with a conflicting one, and it composes naturally with data-brand. Tailwind's dark variant uses a class by default and can be configured for an attribute selector. Pick one and be consistent.
How do I handle theming in a server-rendered app?
Store the preference in a cookie in addition to localStorage, read it on the server, and render <html data-theme="dark"> directly. That removes the flash entirely without relying on a client script. Where the user is on system, you can't know the OS preference server-side, so fall back to the inline script for that case — or render with color-scheme: light dark and let CSS media queries handle it.
Should I use CSS light-dark()?
light-dark(#fff, #121212) is a concise way to express a pair inline and works with color-scheme. It's genuinely useful for one-off values. For a full design system, separate token blocks per theme remain better: they support more than two themes, work with multi-brand selectors, and keep each theme's palette readable as a set.
How much extra work is a second theme?
With a semantic token layer, roughly a day for the token values plus a few days of refinement — image handling, shadows, illustration variants, and contrast verification. Without one, it's an audit of every component and an ongoing tax on every new feature. The cost is almost entirely determined by the architecture decision made earlier.
Does dark mode save battery?
On OLED displays, meaningfully — dark pixels are genuinely off, and a mostly-dark interface can reduce screen power substantially. On LCD, essentially not at all, since the backlight is on regardless. It's a real benefit on modern phones and not a reason on its own to build the feature; user preference and low-light comfort are the actual motivations.
Related Topics
- Design Tokens — The semantic layer that makes theming possible
- Color Systems for UI — Building both palettes correctly
- Design Systems — Distributing themes across products
- CSS — Custom properties, cascade, and
color-scheme - Accessibility — Contrast and user preference media queries
- Tailwind — Dark variant configuration
- Next.js — Server-rendered theming and
next-themes - View Transitions — Animating a theme switch, if you must