Dark Mode & Theming
Design and implement theme-aware interfaces — token architecture, system detection, persistence, toggle patterns, testing, and visual asset considerations.
Dark mode is not simply inverting colours. It is a separate visual design that must maintain readability, preserve brand intent, and respect user preference across every component in the system. A well-implemented theme system makes dark mode feel inevitable rather than retrofitted.
Token Architecture for Theming
The foundation of any theme system is a token architecture that separates primitive values from semantic roles. Do not hard-code colours per theme. Define semantic tokens that reference different primitives in each theme.
Layer structure
| Layer | Purpose | Example |
|---|---|---|
| Primitive tokens | Raw colour values (OKLCH) | --blue-500: oklch(0.42 0.17 265) |
| Semantic tokens | Role-based references | --color-primary: var(--blue-500) |
| Component tokens | Component-specific overrides | --button-primary-bg: var(--color-primary) |
Token categories for theming
:root {
/* Backgrounds — reversed in dark mode */
--color-background: oklch(0.985 0.001 260);
--color-card: oklch(0.975 0.002 260);
--color-popover: oklch(0.99 0.001 260);
/* Foreground — high contrast in light, slightly adjusted in dark */
--color-foreground: oklch(0.15 0.01 260);
/* Brand colours — same hue, adjusted lightness and chroma per theme */
--color-primary: oklch(0.42 0.17 265);
--color-primary-foreground: oklch(0.99 0.001 260);
/* Muted surfaces — subtle backgrounds for disabled or secondary areas */
--color-muted: oklch(0.95 0.003 260);
--color-muted-foreground: oklch(0.50 0.01 260);
/* Accent — surfaces that need emphasis without being primary */
--color-accent: oklch(0.60 0.13 245);
--color-accent-foreground: oklch(0.99 0.001 260);
/* Borders and rings */
--color-border: oklch(0.88 0.006 260);
--color-ring: var(--color-primary);
}See Design System for the complete token naming convention and colour system documentation.
Dark mode overrides
In dark mode, surface colours deepen, foreground colours brighten, and accent colours often shift to be more saturated to compensate for the dark background.
.dark {
--color-background: oklch(0.14 0.01 260);
--color-foreground: oklch(0.97 0.002 260);
--color-card: oklch(0.18 0.008 260);
--color-popover: oklch(0.17 0.008 260);
--color-primary: oklch(0.68 0.16 265);
--color-primary-foreground: oklch(0.98 0.002 260);
--color-muted: oklch(0.20 0.006 260);
--color-muted-foreground: oklch(0.72 0.005 260);
--color-accent: oklch(0.52 0.11 245);
--color-border: oklch(0.26 0.006 260);
}Light / Dark Implementation
CSS custom properties approach
The most reliable theming approach uses :root for light tokens and a class-based override for dark tokens. This integrates with any framework and works with Tailwind v4's @theme inline directive.
@theme inline {
--color-background: var(--color-background);
--color-foreground: var(--color-foreground);
--color-primary: var(--color-primary);
/* Each semantic token maps to its custom property */
}OKLCH colour space
Use the OKLCH colour space for all tokens. OKLCH provides perceptual uniformity — a 10-point lightness change looks equally different at any point on the scale — and gives independent control over lightness, chroma (saturation), and hue.
/* OKLCH gives you three independent levers */
--color-primary: oklch(0.42 0.17 265);
/* ↑ ↑ ↑ */
/* lightness chroma hue (blue) */Benefits for theming:
- Lightness swaps: Dark mode only adjusts lightness while preserving hue and chroma.
- Chroma reduction: Lower chroma values prevent oversaturated colours on dark backgrounds.
- Hue consistency: The same hue value across both themes ensures brand colour remains recognisable.
Avoid HSL for theming. HSL is not perceptually uniform — a 10% lightness change looks very different in blue vs. yellow, making cross-theme adjustments unpredictable. OKLCH is the modern standard and supported in all current browsers.
System Detection & Persistence
Detecting user preference
The browser exposes the user's system-level preference via prefers-color-scheme. Use it as the default, but always allow manual override.
import { useTheme } from 'next-themes';
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
</button>
);
}The next-themes library handles:
- Reading
prefers-color-schemeon first visit - Persisting the user's choice to
localStorage - Applying the
.darkclass to<html> - Preventing flash of wrong theme (via a blocking inline script)
Persistence strategy
| Source | Priority | When used |
|---|---|---|
localStorage | Highest | Returning user who previously toggled |
prefers-color-scheme | Medium | New visitor with a system preference |
| Light default | Lowest | Visitor with no preference or preference blocked |
Toggle Patterns
Three-state toggle
Offer three states: light, dark, and system (follow OS preference). This respects users who switch their system theme during the day.
function ThemeToggle() {
const { theme, setTheme } = useTheme();
const options = [
{ value: 'light', icon: SunIcon, label: 'Light' },
{ value: 'dark', icon: MoonIcon, label: 'Dark' },
{ value: 'system', icon: MonitorIcon, label: 'System' },
];
return (
<div className="flex gap-1 rounded-lg border p-1">
{options.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
aria-label={label}
data-active={theme === value}
className="rounded-md p-2 data-[active=true]:bg-accent"
>
<Icon className="size-4" />
</button>
))}
</div>
);
}Transition between themes
Add a smooth transition to prevent jarring instant switches. CSS transition on the :root properties creates a crossfade effect.
:root {
transition: background-color 0.3s ease, color 0.3s ease;
}Dark Mode for Visual Assets
Images, icons, and illustrations designed against a light background may not work on dark surfaces.
| Asset type | Dark mode treatment |
|---|---|
| Logos | Provide a separate inverted or filled version for dark backgrounds |
| Illustrations | Use SVG with currentColor or CSS custom properties for fills |
| Screenshots & photos | Apply a CSS brightness or opacity reduction: filter: brightness(0.8) |
| Icon sprites | Use currentColor fill so icons inherit the text colour |
| Charts & graphs | Provide a separate dark colour palette for chart series colours |
/* Example: dim screenshots in dark mode */
.dark img[src*="screenshot"] {
filter: brightness(0.8) contrast(1.1);
border-color: var(--color-border);
}Testing Strategy
| Check | How to test |
|---|---|
| Contrast passes WCAG AA in both themes | Automated check with axe-core or Lighthouse |
| All surfaces are reachable | Manual walkthrough of every route in both themes |
| Images have appropriate treatment | Visual check — no white-background logos floating on dark surfaces |
| Transition is smooth | Toggle rapidly between themes — no flicker or layout shift |
prefers-color-scheme is respected | Set OS preference, visit fresh — correct theme loads |
| Manual override persists | Toggle away from system preference, refresh — preference is saved |
| No flash of wrong theme | Hard reload or clear localStorage — inline script blocks flash |
The most commonly missed dark-mode bug is a third-party embed or modal that ignores the theme class. Test widgets, maps, and iframes explicitly.
Prototyping & Validation
Build and test interface hypotheses cheaply before committing to implementation. The goal is to discover problems at the lowest possible cost.
Component Development
Build components against their specifications with correct prop APIs, variant handling, accessibility attributes, and test coverage.