Dark Mode Isn't a Color Flip: What Actually Breaks When You Ship It
Most dark mode implementations are a broken CSS variable swap held together with hope. Here's what breaks in production — shadows, images, brand color, contrast — and how to build a dark theme that doesn't feel like a bug.

Every few months a client asks us to "add dark mode." Every few months we have the same conversation: dark mode is not a color inversion, it is a second design system that happens to share your component tree. Skip that framing and you ship a product where shadows disappear, brand colors glow radioactive, and screenshots in your marketing site look like they were rendered on a broken monitor.
Here is what actually breaks when a team treats dark mode as a CSS variable swap, and what to do about it at the token layer before it lands in production.
The core mistake: inverting instead of re-designing
The typical implementation looks like this: pick a dark background, pick a light foreground, flip the two custom properties, ship. It works for the first screen you look at. Then you open a modal, and the modal that was elevated above the page with a subtle drop shadow is now floating on a dark background where shadows are invisible. The modal looks like it is inlaid into the page. Depth is gone.
Dark mode is not the light theme with colors inverted. It is a different medium. On a light UI, elevation reads through shadow. On a dark UI, elevation reads through surface lightness — higher surfaces get lighter, not darker. Material Design figured this out years ago and most teams still ignore it.
If your tokens do not have a concept of "surface level," you cannot fix this with a color swap. You have to model it.
What a surface-aware token set looks like
:root {
/* Light theme: elevation via shadow */
--surface-base: #ffffff;
--surface-raised: #ffffff;
--surface-overlay: #ffffff;
--shadow-raised: 0 1px 3px rgb(0 0 0 / 0.08);
--shadow-overlay: 0 12px 32px rgb(0 0 0 / 0.16);
}
[data-theme="dark"] {
/* Dark theme: elevation via surface lightness */
--surface-base: #0f1115;
--surface-raised: #171a20;
--surface-overlay: #1f232b;
--shadow-raised: 0 1px 3px rgb(0 0 0 / 0.4);
--shadow-overlay: 0 12px 32px rgb(0 0 0 / 0.5);
}
Notice two things. First, the light theme surfaces are all #ffffff — depth comes entirely from shadow. Second, the dark theme surfaces get progressively lighter, and shadows still exist but are heavier because they need to punch through a dark background to be visible. That is the minimum viable elevation model.
Brand colors will betray you
Your brand blue that looks confident on a white background will look like a laser pointer on a dark one. Highly saturated colors at high luminance vibrate against dark surfaces. Users get eye strain. Buttons stop looking like buttons and start looking like warnings.
The fix is to keep the hue and drop the saturation and luminance for dark mode. If your primary is #2563eb on light, it might become #60a5fa on dark — same family, lower saturation, higher perceived readability. This is why a single --color-primary token that maps to one hex is a dead end. You need semantic tokens on top of a palette.
:root {
--color-primary: var(--blue-600);
--color-primary-hover: var(--blue-700);
--color-primary-fg: white;
}
[data-theme="dark"] {
--color-primary: var(--blue-400);
--color-primary-hover: var(--blue-300);
--color-primary-fg: var(--gray-950);
}
The --color-primary-fg flip is the one people miss. Your primary button's text color needs to change too, because white text on --blue-400 fails contrast. Pair each interactive color with its own foreground token and stop guessing.
Test against WCAG at the token level, not the component level
If you check contrast per-component, you will miss half of them. Check every semantic pair once, at the token layer, and log the ratio. A tiny script in your design tokens pipeline can fail the build if any semantic pair drops below 4.5:1 for body text or 3:1 for large text and UI components.
We do this with a token linter that runs in CI. The exact tool matters less than the discipline — one authoritative table of pairs, tested on every commit. If you want a starting point, our design systems work leans heavily on this pattern.
Images, illustrations, and the transparent PNG problem
Here is a bug we have shipped and immediately regretted more than once: a logo delivered as a PNG with dark navy text on a transparent background. Fine on light mode. Invisible on dark mode.
Same with product screenshots, spot illustrations, and any PDF preview. They were designed for a white page and now they are floating on #0f1115.
Realistic options, in order of effort:
- SVG with
currentColor. Icons and simple illustrations should usecurrentColorfor strokes and fills so they inherit the text color. This is the cheapest win. - Two asset variants. For raster assets or illustrations with real color, ship a
-lightand-darkvariant and swap with<picture>andprefers-color-scheme, or with a data attribute. - A neutral card background. For assets you cannot re-export (client logos, third-party screenshots), wrap them in a light-neutral card even in dark mode. It looks intentional. Users read it as a "canvas."
<picture>
<source srcset="/img/hero-dark.webp" media="(prefers-color-scheme: dark)">
<img src="/img/hero-light.webp" alt="Dashboard overview">
</picture>
This works, but note that <picture> responds to the OS preference, not to your in-app theme toggle. If you support a manual override — and you should — you need JavaScript to swap src when the user picks a theme that differs from their OS.
The theme toggle itself has three states, not two
"Light" and "dark" is not enough. The correct state model is:
- System (follow
prefers-color-scheme, the default) - Light (explicit override)
- Dark (explicit override)
Persist the choice in localStorage, but hydrate it before the first paint or you will get a flash of the wrong theme on every page load. In a Next.js or Astro setup that means a small blocking script in <head> that reads storage and sets the data-theme attribute on <html> before React hydrates.
<script>
(function () {
var stored = localStorage.getItem('theme');
var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var theme = stored === 'light' || stored === 'dark' ? stored : system;
document.documentElement.dataset.theme = theme;
})();
</script>
Ugly, blocking, necessary. The flash of unstyled theme is the single most reported bug we see when teams DIY this.
Charts, code blocks, and the syntax highlighting trap
Data visualization is where dark mode implementations quietly collapse. The categorical color palette you chose for light mode — with careful attention to distinguishability against white — will not survive on dark. Some colors will disappear, others will clash.
Maintain two palettes. Chart libraries like Recharts, Chart.js, and D3 do not care what colors you pass; feed them from CSS variables and let the theme resolve at runtime.
Syntax highlighting has the same problem. Prism's okaidia looks great on dark, terrible on light. Shiki lets you specify separate light and dark themes and generates dual-highlighted output. Use it.
Motion and focus states need a second look
On dark backgrounds, subtle motion becomes invisible and focus rings you carefully designed for a white page can either wash out or become unbearable. Two specific things to re-verify in dark mode:
- Focus rings need enough contrast against both the surface behind them and the element they surround. A
--ring: rgb(59 130 246 / 0.5)translucent ring can look sharp on white and muddy on dark. Consider a solid ring token per theme. - Hover state deltas. A 4% brightness bump on a light button reads clearly; the same 4% on a dark button might be imperceptible. In dark mode, hover states often need larger luminance changes to feel responsive.
Where we'd start
If you are retrofitting an existing product, do not open a Figma file and start picking dark colors. Do this instead:
- Audit every color reference in your codebase and route them through semantic tokens (
--surface-base,--text-primary,--border-subtle). Do this in light mode first. Ship it. Nothing changes visually. - Add a dark theme by overriding those tokens only. If a component breaks, it means you missed a hardcoded color — fix it upstream, not in the component.
- Add the surface elevation model before you touch shadows.
- Run automated contrast checks on your token pairs in CI.
- Ship dark mode to internal users for two weeks before public release. Every screen you have not personally clicked through in dark mode has a bug.
Dark mode done well is invisible — users forget which mode they are in. Dark mode done as a color flip is the kind of thing that ends up in a screenshot on social media with the caption "who approved this." Pick the first one.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Hover States Are a Lie on Touch: Designing Interactions That Work on Every Input
Hover is a desktop assumption baked into most component libraries. Here's how to design and build interactions that behave correctly across mouse, touch, stylus, and hybrid devices in 2026.
Stop Animating Everything: A Motion Budget That Respects Users and CPUs
Most product UIs animate too much, too long, and at the wrong easing. Here's the motion budget we use to keep interfaces feeling fast, accessible, and intentional.
Form Validation Timing: The UX Decision That Breaks Most Sign-up Flows
Validating on every keystroke feels responsive but punishes users mid-thought. Validating only on submit feels rude. Here's the timing model we use to make forms feel calm and competent.
