TL;DR: Most dark themes are just “invert the colors and ship it.” The result is unreadable text, invisible focus states, and contrast that fails WCAG. A good dark theme needs intentional color mapping, separate contrast ratios for both modes, and consideration for reduced motion and forced colors. Here’s how to build one properly.
Dark mode is table stakes in 2026. Every major OS supports it, browsers respect prefers-color-scheme, and users expect it. But most implementations are bad.
The common approach: take a light theme, flip white to black, and adjust a few grays. The result is washed-out text, invisible borders, and contrast ratios that technically pass WCAG AA but feel terrible to read.
A good dark theme is a separate design system, not an inversion of your light one.
Table of Contents
- The Core Problem: Inverted ≠ Accessible
- Contrast Ratios: The Real Numbers
- Color Mapping: Not Just Grays
- Focus States: The Forgotten Failure
- Respecting
prefers-reduced-motion - Respecting
forced-colors - The Implementation Pattern
- The Checklist
The Core Problem: Inverted ≠ Accessible
In a light theme, you create depth with shadows. White surfaces float above gray backgrounds. It’s intuitive because it mirrors real-world lighting.
Dark themes can’t just reverse this. If you invert shadows, you get bright glows that look neon. If you use the same shadow values, they disappear into the dark background.
Instead, dark themes create depth with surface lightness. Higher surfaces are lighter. This is Material Design’s elevation system, and it works because it maps to how light behaves in dark environments — surfaces closer to you catch more light.
:root {
/* Light theme — depth via shadows */
--surface-0: #ffffff;
--surface-1: #ffffff;
--surface-2: #ffffff;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
[data-theme='dark'] {
/* Dark theme — depth via surface lightness */
--surface-0: #09090b; /* zinc-950 */
--surface-1: #18181b; /* zinc-900 — raised */
--surface-2: #27272a; /* zinc-800 — highest */
--shadow: none; /* shadows are invisible on dark backgrounds */
}
Contrast Ratios: The Real Numbers
WCAG AA requires 4.5:1 contrast for normal text and 3:1 for large text. WCAG AAA bumps this to 7:1 and 4.5:1 respectively.
The trap: a color pair that passes in light mode often fails in dark mode, and vice versa. You need to check contrast independently for each theme.
Common failures in dark themes:
| Text Color | Background | Ratio | WCAG AA |
|---|---|---|---|
#71717a (zinc-500) | #09090b (zinc-950) | 3.8:1 | ❌ Fail |
#a1a1aa (zinc-400) | #09090b (zinc-950) | 6.3:1 | ✅ Pass |
#a1a1aa (zinc-400) | #18181b (zinc-900) | 5.2:1 | ✅ Pass |
#71717a (zinc-500) | #18181b (zinc-900) | 3.2:1 | ❌ Fail |
The takeaway: zinc-500 text on dark backgrounds fails. Use zinc-400 as your minimum for body text in dark themes. For secondary/muted text, zinc-500 only works on zinc-950 surfaces if the text is large (18px+ or 14px bold).
Checking Contrast in Practice
Don’t eyeball it. Use tooling:
# In Chrome DevTools:
# 1. Inspect an element
# 2. Click the color swatch in the Styles panel
# 3. The contrast ratio appears with AA/AAA pass/fail indicators
Or use the axe browser extension for a full-page audit. Run it in both light and dark modes — they’ll surface different failures.
Color Mapping: Not Just Grays
A dark theme needs more than inverted grays. Every semantic color (success, error, warning, info) needs a dark-specific variant.
The rule: in dark mode, use desaturated, lighter versions of semantic colors. Saturated colors on dark backgrounds create visual vibration — they appear to glow and are fatiguing to read.
:root {
--color-success: #16a34a; /* green-600 — vivid on white */
--color-error: #dc2626; /* red-600 */
}
[data-theme='dark'] {
--color-success: #4ade80; /* green-400 — softer on dark */
--color-error: #f87171; /* red-400 */
}
For backgrounds behind semantic colors (error banners, success toasts), use very low opacity:
[data-theme='dark'] {
--error-bg: rgba(239, 68, 68, 0.1); /* red-500 at 10% */
--error-border: rgba(239, 68, 68, 0.2);
}
This prevents the “neon sign” effect while maintaining clear meaning.
Focus States: The Forgotten Failure
Most dark themes break focus indicators. The default browser outline is a thin blue ring that’s visible on white backgrounds but invisible on dark ones.
Fix this explicitly:
[data-theme='dark'] *:focus-visible {
outline: 2px solid #e4e4e7; /* zinc-200 — visible on dark */
outline-offset: 2px;
}
Or use a ring approach in Tailwind:
<button
class="focus-visible:ring-2 focus-visible:ring-zinc-300 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
Click me
</button>
The ring-offset must match your background color, or you get a visible gap that looks broken. Set it per surface level.
Respecting prefers-reduced-motion
Dark themes often use subtle animations — fading between themes, hover glows, pulse effects on active indicators. Users who have reduced motion enabled shouldn’t see these.
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
In Tailwind, use the motion-reduce: variant:
<span class="animate-pulse motion-reduce:animate-none">●</span>
This is especially important for the “pulsing green dot” pattern (used for availability indicators). A pulsing animation that never stops is a genuine accessibility issue for users with vestibular disorders.
Respecting forced-colors
Windows High Contrast mode overrides all your custom colors. If your dark theme relies on subtle color differences for hierarchy, it’ll break.
Test with:
@media (forced-colors: active) {
/* Forced colors mode is active */
/* Borders become critical — they're the only way to show boundaries */
.card {
border: 1px solid ButtonText;
}
}
The rule of thumb: if you remove all color from your dark theme and rely only on borders and spacing, the layout should still make sense. That’s what forced-colors users see.
The Implementation Pattern
Here’s the approach I use for every project:
- Define semantic tokens in CSS custom properties — not raw color values in components
- Map tokens independently for light and dark themes
- Check contrast for every text/background pair in both modes using DevTools
- Test focus states by tabbing through the entire page in dark mode
- Test reduced motion by enabling it in OS settings and verifying no persistent animations
- Test forced colors in Windows High Contrast mode (or Chrome DevTools emulation)
/* The token system */
:root {
--text-primary: #09090b;
--text-secondary: #52525b;
--text-muted: #a1a1aa;
--bg-page: #ffffff;
--bg-surface: #ffffff;
--bg-elevated: #fafafa;
--border-default: #e4e4e7;
}
[data-theme='dark'] {
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--bg-page: #09090b;
--bg-surface: #18181b;
--bg-elevated: #27272a;
--border-default: #27272a;
}
Every component references these tokens. Swapping themes changes the tokens, not the components. Zero duplication, one source of truth.
The Checklist
Before shipping a dark theme:
- All body text passes WCAG AA contrast (4.5:1)
- Muted/secondary text passes on its actual surface (not just the page background)
- Focus indicators are visible on all surface levels
- Semantic colors (red, green, yellow) are desaturated for dark backgrounds
- Borders are visible — not just relying on shadow/elevation
-
prefers-reduced-motiondisables all decorative animation - Theme toggle doesn’t cause a flash of unstyled content (FOUC)
- Tested in Windows High Contrast mode
Dark mode isn’t a feature toggle. It’s a parallel design system that deserves the same attention as your light theme.