Dark Mode in a Design System: Semantic Tokens That Work
Semantic color tokens are the only way dark mode scales in a real design system. Here's how to build them right without doubling your CSS.
Why Most Dark Mode Implementations Fall Apart
Honestly, dark mode done wrong is worse than no dark mode at all. You end up with a codebase full of dark:bg-gray-900 scattered everywhere, designers asking why the card looks different on this page versus that one, and every new component becoming a small negotiation about which shade of slate to use.
The root problem is almost always the same: teams reach for raw color values instead of semantic tokens. A token called --color-surface-card tells you *what* the color is for. A token called --color-gray-800 tells you nothing. When you need dark mode, the semantic token flips its value. The raw value just... sits there, forcing you to add a parallel dark: override for every single usage.
This isn't a Tailwind problem or a CSS problem. It's an architecture problem. And fixing it means going back to token design before you write a single line of theme code.
The Two-Layer Token Model
There's a mental model that actually scales: split your tokens into two layers. The first layer is your primitive palette — every raw color value your brand uses, named literally. --color-blue-500: #3b82f6. --color-neutral-950: #0a0a0a. These never appear in components. They're a reference library.
The second layer is your semantic tokens. These reference primitives and carry *intent*. --color-bg-default maps to --color-neutral-50 in light mode and --color-neutral-950 in dark mode. --color-text-muted maps to --color-neutral-500 in light, --color-neutral-400 in dark. Your components consume *only* semantic tokens.
Now switching themes is one operation: swap the semantic layer's mappings. Every component that uses --color-bg-default updates automatically. You don't touch components at all. If you're curious how this connects to a broader color system design strategy, that article walks the full palette construction from scratch.
The naming convention matters too. Use role-based names: bg, surface, border, text, icon, interactive. Then add a state suffix when needed: -default, -subtle, -muted, -emphasis, -inverse. That's the entire vocabulary. Resist the urge to add a --color-sidebar-header-hover-dark token — that's a component style, not a system token.
Setting Up CSS Custom Properties for Theming
The implementation is cleaner than most people expect. You define your semantic tokens in :root for light mode, then override them in a [data-theme='dark'] attribute selector — or under a .dark class if you're using Tailwind v4.0.2's dark mode class strategy.
Here's a minimal but real example of the token layer swap:
:root {
/* Primitive palette */
--color-neutral-50: #fafafa;
--color-neutral-100: #f5f5f5;
--color-neutral-800: #262626;
--color-neutral-900: #171717;
--color-neutral-950: #0a0a0a;
--color-blue-500: #3b82f6;
--color-blue-400: #60a5fa;
/* Semantic layer — light defaults */
--color-bg-default: var(--color-neutral-50);
--color-bg-surface: #ffffff;
--color-bg-subtle: var(--color-neutral-100);
--color-text-default: var(--color-neutral-900);
--color-text-muted: var(--color-neutral-500);
--color-border-default: rgba(0, 0, 0, 0.08);
--color-interactive: var(--color-blue-500);
}
[data-theme='dark'],
.dark {
/* Semantic layer — dark overrides only */
--color-bg-default: var(--color-neutral-950);
--color-bg-surface: var(--color-neutral-900);
--color-bg-subtle: var(--color-neutral-800);
--color-text-default: #f5f5f5;
--color-text-muted: var(--color-neutral-400);
--color-border-default: rgba(255, 255, 255, 0.08);
--color-interactive: var(--color-blue-400);
}Notice that --color-border-default uses rgba(255,255,255,0.08) in dark mode instead of a named primitive. That's intentional — translucent borders behave better over varied surface colors than solid ones. Don't feel like you have to route everything through primitives. Pragmatism first.
Wiring Semantic Tokens Into Tailwind v4
Tailwind v4 changed how you extend the theme. Instead of tailwind.config.js, you configure inside your CSS using @theme. This makes the token integration much more direct — your CSS custom properties become Tailwind utilities with zero JavaScript.
@import 'tailwindcss';
@theme {
--color-bg-default: var(--color-bg-default);
--color-bg-surface: var(--color-bg-surface);
--color-bg-subtle: var(--color-bg-subtle);
--color-text-default: var(--color-text-default);
--color-text-muted: var(--color-text-muted);
--color-border-default: var(--color-border-default);
--color-interactive: var(--color-interactive);
}Now you can write bg-bg-default, text-text-muted, border-border-default in your JSX. When the dark class or data-theme='dark' attribute is applied to the root, every token flips automatically. No dark: prefix needed on individual elements. That's the payoff.
For the theme toggle itself, you need a bit of React. Check out the theme toggle in React walkthrough which covers the localStorage persistence, prefers-color-scheme detection, and the flicker-prevention script that you embed in your <head> before hydration.
Handling Elevation and Layered Surfaces
Here's a detail that bites almost every dark mode implementation: elevation. In light mode, elevation is shown with drop shadows. Darker shadow = higher elevation. In dark mode, shadows don't read well against dark backgrounds. The pattern that actually works is *lightening* the surface instead of darkening the shadow.
So you need a token family like --color-bg-elevated-1, --color-bg-elevated-2, --color-bg-elevated-3. In light mode, these all equal #ffffff (or very close to it) and you rely on box-shadow for the elevation effect. In dark mode, they become progressively lighter: #1a1a1a, #222222, #2a2a2a. The shadow can stay subtle — something like 0 2px 8px rgba(0,0,0,0.4) — while the surface lightness does the visual work.
This pairs naturally with a good spacing system, because the padding and gap values inside elevated surfaces (typically 16px or 24px) need to stay consistent across themes even as the background shifts. Tokens handle color; the spacing scale handles layout. Keep them separate.
Dark Mode Tokens for Interactive States
Interactive elements are where token systems get complicated. A button needs default, hover, active, focused, and disabled states — and each of those needs to look correct in both themes. That's potentially ten token values per component just for the background.
The shortcut that actually holds up: define your interactive tokens in terms of opacity shifts over a semantic base. Rather than hardcoding every hover value, use color-mix() or a CSS calc() approach. Alternatively, define explicit tokens but only for the states that diverge significantly between themes.
// Button.tsx — consuming only semantic tokens
const Button = ({ children, variant = 'primary' }: ButtonProps) => {
const baseStyles = [
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium',
'transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-interactive',
];
const variantStyles = {
primary: [
'bg-interactive text-white',
'hover:opacity-90 active:opacity-80',
],
ghost: [
'bg-transparent text-text-default',
'hover:bg-bg-subtle active:bg-bg-subtle/80',
'border border-border-default',
],
};
return (
<button className={[...baseStyles, ...variantStyles[variant]].join(' ')}>
{children}
</button>
);
};The ghost button here uses border-border-default, which in dark mode resolves to rgba(255,255,255,0.08). You never wrote a dark: override. That's the system paying off. And when you document this in Storybook — which the Storybook component library guide covers in depth — you can add a theme toolbar toggle to preview both modes without touching your code.
Testing Dark Mode Tokens for Accessibility
What's the point of dark mode if nobody can actually read it? Contrast ratios are where token systems get stress-tested. WCAG 2.1 AA requires 4.5:1 for normal text and 3:1 for large text. Dark mode often fails this because developers just flip to a dark background without verifying that their text tokens still hit those ratios.
Run both theme variants through a contrast checker during token definition — not after. If --color-text-muted over --color-bg-default fails in dark mode, you need to adjust the muted token value *for dark mode specifically*. That's a one-line change in your dark theme block. Way easier to fix in tokens than to find every usage of a class and add an override.
The WCAG accessibility guide goes deeper on automated contrast auditing you can add to your CI pipeline. Worth reading if you're shipping to any regulated industry or just want to avoid the support tickets.
Distributing Tokens Across a Monorepo
If you're running a monorepo with multiple apps sharing one design system, your token definitions live in a shared package. The CSS file gets imported once in each app's root stylesheet. Nothing exotic. But you'll want to version your token package separately from your component package — token changes are often breaking changes, and you want to control that rollout.
One practical pattern: export your tokens as both a CSS file (for runtime use) and a JSON file (for build-time tooling like style-dictionary or for feeding into your Figma plugin). The JSON source of truth means your designers and your codebase are always looking at the same values. Drift between design files and production code is one of those slow-burning problems that causes months of confusion.
Whether you're using a raw CSS approach or something like style-dictionary generating your primitives automatically, the semantic layer stays the same. That stability is the whole point. Your components don't care how the token values are generated — they just consume them.
FAQ
CSS custom properties. They update instantly across the entire DOM with a single attribute change, they work with any framework or no framework, and they don't require a re-render. JavaScript token objects only make sense if you're doing something unusual like server-side dynamic theming per user on every request.
Inject a small inline script into your HTML <head> — before any stylesheets — that reads localStorage and sets the data-theme attribute or dark class on <html> synchronously. Since it runs before paint, there's no flash. Next.js users can put this in _document.tsx or in the new app/layout.tsx as a <script> tag with dangerouslySetInnerHTML.
You can mix them, but it creates two systems running in parallel which gets messy fast. The point of semantic tokens is that you don't need dark: prefixes at all. If you're migrating an existing codebase, use tokens for all new components and gradually remove dark: overrides as you refactor old ones.
A primitive token is a named raw value: --color-blue-500: #3b82f6. It says what color it is. A semantic token references a primitive and carries intent: --color-interactive: var(--color-blue-500). It says what the color is *for*. Components consume semantic tokens. Primitives never appear in component code.
When you can't remember them all without looking them up, you have too many. A workable system for most products fits inside around 30-40 semantic tokens: roughly 8 for backgrounds, 6 for text, 4 for borders, 6 for interactive states, and a handful for feedback colors (error, success, warning, info). Anything beyond that usually signals you're encoding component-specific styles as tokens.
Yes. Styled-components can reference CSS custom properties directly in template literals: background: var(--color-bg-default). The theme switching still happens at the CSS level via attribute or class on the root element. You get the same zero-override benefit regardless of whether your styles live in .css files or JavaScript strings.