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

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

References