Dark Mode Color Tokens: Building a Theme That Doesn't Break Everything
Stop shipping dark mode as an afterthought. Here's how to build a real color token system with CSS variables that survives theming, components, and third-party libraries.
Why Your Dark Mode Keeps Breaking
You flipped prefers-color-scheme: dark, inverted a few hex values, shipped it, and then watched your designer file six bug tickets the next morning. Sound familiar? The problem isn't the CSS — it's that you never had a token system in the first place. You had hardcoded colors everywhere, and dark mode just exposed that debt at scale.
Honestly, most dark mode implementations in 2026 are still just "make things darker." That's not a theme. That's a coat of paint. Real theming means every color in your UI resolves through a named semantic token — --color-surface, --color-text-primary, --color-border-subtle — that maps to different raw values depending on the active mode. Swap the mapping, the whole UI follows. No hunting through 47 component files.
The deeper issue is that most codebases mix *primitive* tokens (the raw palette: blue-500, gray-900) with *semantic* tokens (what a color *means*: text-default, bg-card) in the same layer. When you try to flip to dark mode, you end up changing primitives directly instead of remapping semantics. That's what produces broken button states, invisible focus rings on 1px borders, and card backgrounds that blend into the page.
The Two-Layer Token Model You Actually Need
Start with primitives. These are your raw color palette — every shade, every stop. They don't mean anything yet. They're just values.
:root {
/* Primitives — never use these directly in components */
--blue-100: #dbeafe;
--blue-500: #3b82f6;
--blue-900: #1e3a5f;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-800: #1f2937;
--gray-900: #111827;
--gray-950: #030712;
--white: #ffffff;
--black: #000000;
}Then build your semantic layer on top. These tokens carry *intent* — and this is the only layer your components should ever touch. Light mode values go on :root, dark mode overrides go on [data-theme='dark'] or .dark. Pick one pattern and enforce it in code review.
:root {
/* Semantic tokens — light mode defaults */
--color-bg-base: var(--gray-50);
--color-bg-surface: var(--white);
--color-bg-elevated: var(--gray-100);
--color-text-primary: var(--gray-900);
--color-text-secondary:var(--gray-600);
--color-text-disabled: var(--gray-400);
--color-border-default:var(--gray-200);
--color-border-subtle: var(--gray-100);
--color-accent: var(--blue-500);
--color-accent-hover: var(--blue-600);
}
[data-theme='dark'] {
--color-bg-base: var(--gray-950);
--color-bg-surface: var(--gray-900);
--color-bg-elevated: var(--gray-800);
--color-text-primary: var(--gray-50);
--color-text-secondary:var(--gray-400);
--color-text-disabled: var(--gray-600);
--color-border-default:var(--gray-700);
--color-border-subtle: var(--gray-800);
--color-accent: var(--blue-400);
--color-accent-hover: var(--blue-300);
}That's it. Notice how --color-accent shifts from blue-500 to blue-400 in dark mode — you need that because blue-500 on a near-black background already passes contrast ratios, but blue-500 on gray-900 is borderline. Worth noting: always run your semantic tokens through a WCAG contrast checker before shipping. A 4.5:1 ratio for body text is the minimum, not a suggestion.
Wiring Tokens to Components (Without Losing Your Mind)
Once the token layer is in place, component CSS becomes almost boring. Every property that varies by theme reaches for a semantic variable. No raw hex values, no Tailwind arbitrary values like bg-[#1a1a2e] that you'll forget to update when the palette changes.
// Card.module.css
.card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
border-radius: 12px;
padding: 24px;
color: var(--color-text-primary);
}
.card:hover {
background: var(--color-bg-elevated);
border-color: var(--color-border-subtle);
}
.card__label {
color: var(--color-text-secondary);
font-size: 0.75rem;
}In practice, the first time you do this for a real project, it feels like overhead. You're defining tokens, then primitives, then wiring them up — three files instead of one. But fast-forward to the first time a designer says "we want to test an OLED-black mode" and you can ship it by adding 20 lines to your token file instead of touching every component. That payoff is real.
One more thing — if you're using Tailwind, you can map semantic tokens into your tailwind.config.js so you get utility classes that respect the theme automatically. Something like text-text-primary and bg-bg-surface. It's a bit verbose but it keeps Tailwind's DX without the theming landmines.
The Mistakes That Will Still Bite You
You can have a perfect token system and still ship broken dark mode. Here's where it usually goes wrong.
Shadows. Box shadows are almost always hardcoded as dark semi-transparent blacks (0 4px 16px rgba(0,0,0,0.1)). In dark mode, that shadow often vanishes because the surface behind is already dark. You need a separate shadow token — on dark backgrounds, shadows typically need to go to rgba(0,0,0,0.4) or higher, or you switch to a slightly lighter border on --color-bg-elevated surfaces instead. Some design systems like to use inset highlights (think neumorphism) rather than drop shadows in dark mode, and that's a valid call.
Images and media. A white logo on a dark background is an obvious fix, but filter: brightness(0.85) on photographs can help them feel less washed-out on pure dark surfaces. Token it: --filter-image-default: none in light, --filter-image-default: brightness(0.9) contrast(1.05) in dark. Apply it globally to img elements. Takes 5 minutes and makes a noticeable difference.
Third-party components. This is the painful one. If you're using a date picker, a rich text editor, or a charting library that doesn't consume your CSS variables, you're stuck theming it separately. Check your dependencies early. Libraries that use their own CSS-in-JS runtime usually need a separate theme prop. Factor that into your estimate — it's often 40% of the actual theming work.
Quick aside: be careful with color-scheme: dark on :root. It tells the browser to use dark native scrollbars and form controls, which is what you want — but it also affects Canvas and LinkText system colors in unexpected ways if you're mixing semantic tokens with system colors anywhere.
System Preference vs. Manual Toggle
Do both. Not one or the other. Start with prefers-color-scheme to set a sane default, then layer in a user toggle that writes data-theme to the <html> element and persists it to localStorage. Users who have OS-level dark mode but want your site in light mode — they exist, and they get frustrated when you don't give them an out.
// theme.ts
const STORAGE_KEY = 'empire-theme';
export function initTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored ?? (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
}
export function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem(STORAGE_KEY, next);
}Call initTheme() as early as possible — ideally in a blocking <script> tag in <head>, before your framework hydrates. If you wait until React mounts, you'll get a flash of the wrong theme on every hard refresh. That flash is one of those things that's genuinely hard to un-see once a user reports it.
In Next.js App Router, this means a small inline script in your root layout.tsx. Yes, it's the one case where an inline script is the right call. The next-themes package handles this pattern well if you don't want to roll it yourself — it's been stable since 2023 and handles SSR edge cases you'd otherwise spend a week debugging.
That said, don't let the toggle drive your architecture. The data-theme attribute is just a CSS selector hook. All the real work happened when you built the two-layer token system. The toggle is just flipping which CSS variable values are active. If that causes components to break, you have gaps in your semantic token coverage — not a toggle problem.
Dark Mode and Visual UI Styles
Not every visual style maps cleanly to dark mode, and that's worth thinking about before you commit to a direction. Glassmorphism components actually look *better* in dark mode — the frosted glass effect has more contrast against deep backgrounds and the translucent layers feel richer. If you're building something that leans into that style, dark mode is almost a prerequisite.
On the flip side, styles that rely heavily on light sources — clay textures, paper-like surfaces, heavy inner shadows — tend to fight dark mode at a conceptual level. You end up either faking a different light source (expensive) or the components look like they're bruised rather than elevated. Look, there's no shame in saying "this product is light-mode-only" if your visual language demands it. Better than shipping a dark mode that feels like an afterthought.
The gradient generator is worth bookmarking during dark mode token work. Testing your semantic token values as gradient stops quickly shows you where the contrast breaks down and where steps in your palette feel too close together for dark surfaces. A gradient from --color-bg-base to --color-bg-elevated should be perceptibly different but not jarring — if it looks like one flat color, your gray steps are too close.
One thing the design community got right in 2025: pure black (#000000) for dark mode backgrounds is almost always a mistake. It creates a harsh contrast that feels clinical. Use something like #0a0a0a or #111827 — you'll see a 20px-level difference on OLED screens that makes the whole thing feel intentional.
FAQ
CSS variables are fine for most projects — they're fast, native, and work in every modern browser. Style Dictionary makes sense when you're syncing tokens across multiple platforms (iOS, Android, web) from a single source. Don't add the complexity until you need it.
If you can't name what a token is *for* without checking the component, you have too many. Aim for under 30 semantic tokens per theme layer. More than that usually means you're tokenizing variants that should just be computed (opacity, hover states) rather than stored.
Yes — map your semantic tokens into tailwind.config.js under theme.extend.colors pointing at var(--your-token). Tailwind's dark mode variant then just becomes a way to override data-theme on the root element. They play together fine.
You're reading the stored theme preference after hydration, which is too late. Add a tiny blocking inline script in your root layout <head> that reads localStorage and sets data-theme before the page paints. The next-themes library does this correctly out of the box.