Dark mode is one of the most-requested features in almost every product, and one of the easiest to implement badly. The naive version, a class toggle that flips a few background colors, looks fine in the demo and then falls apart the moment a real design surface appears: a chart with hardcoded hex values, a shadow that’s invisible on dark, an image with a white background floating in a sea of charcoal.
Done well, dark mode is a small amount of upfront structure and almost no ongoing cost. Here’s how to add it so it actually holds up.
Start with tokens, not colors
The single most important decision is to stop referring to colors by what they are and start referring to them by what they do. #ffffff becomes --surface. #111827 becomes --text. #6b7280 becomes --text-muted.
Define both themes as sets of these semantic variables:
:root {
--surface: #ffffff;
--surface-raised: #f9fafb;
--text: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--accent: #4f46e5;
}
:root[data-theme="dark"] {
--surface: #0b0f19;
--surface-raised: #151b28;
--text: #f3f4f6;
--text-muted: #9ca3af;
--border: #232a3b;
--accent: #818cf8;
}
Now your components only ever reference var(--surface), var(--text), and friends. Switching themes is a single attribute change on the root element, and every component follows automatically. This is the abstraction that makes dark mode maintainable, without it, every new component is a fresh opportunity to hardcode a color that breaks in one theme.
If you’re using an AI agent to do the migration, make the token list the first deliverable:
Audit this codebase for hardcoded colors and propose a semantic CSS-variable palette (surface, text, border, accent, etc.) with light and dark values. Show me the token list and where each existing color maps to it, before changing any components.
Respect the operating system, then the user
The correct default is the user’s OS-level preference, exposed through the prefers-color-scheme media query. But once a user makes an explicit choice, that choice wins and persists.
The precedence is: explicit saved choice → OS preference → light fallback. In practice:
const saved = localStorage.getItem("theme");
const osDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = saved ?? (osDark ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
Kill the flash of wrong theme
The most common dark-mode bug is a flash: the page loads light, then snaps to dark once your JavaScript runs. It happens because your theme logic lives in a bundle that loads after the first paint.
The fix is to set the theme in a tiny inline script in the document <head>, before any content renders, so the very first paint is already correct. It’s a few lines, but it’s the difference between dark mode that feels native and dark mode that flickers on every navigation.
The things people forget
- Shadows. Box shadows that read as depth on a light background vanish on dark. Dark themes usually lean on lighter surfaces (
--surface-raised) and subtle borders for elevation instead of shadows. - Images and logos. A logo with baked-in white background will float awkwardly. Provide a theme-aware variant or give it a transparent background.
- Charts and data viz. These almost always carry their own color logic. Route them through the same tokens or they’ll be the one thing that looks broken.
- Illustrations and emoji. Fine on both, usually, but check anything with a light fill.
- Focus rings and states. Hover, focus, and active states need enough contrast in both themes. Don’t only test the resting state.
Roll it out without a big-bang launch
Here’s the part most tutorials skip. You’ve built dark mode. Do you flip it on for every user at once? No, for the same reason you wouldn’t with any other UI-wide change: the blast radius is your entire product, and the failure mode is subtle (a hard-to-read color in one corner of one page) rather than an obvious crash.
A calmer rollout:
- Ship the code with the toggle hidden behind a flag, so it’s in production but invisible. Your team can enable it for their own accounts and click through every screen in dark.
- Turn it on for a small percentage of users. Watch support tickets and session recordings for “I can’t read this.”
- Fix the inevitable one or two contrast issues nobody caught internally.
- Ramp to everyone.
This turns a launch into a non-event. If a contrast problem does slip through, you flip the flag off for the affected cohort and fix it calmly, instead of shipping a hotfix while users squint at unreadable text.
Dark mode is a great first feature to practice this on precisely because the failure mode is gentle. Get the muscle memory here, gate the feature, ramp it, keep the switch handy, and it’s the same muscle you’ll use for the features where the stakes are higher.
ShipSilently makes gating a UI feature behind a percentage rollout a one-line SDK call, evaluated at the edge in under a millisecond. Try it free.