Dark Mode Done Right: Why Most Implementations Look Bad
Toggle dark mode on most websites and you'll see it — washed-out text, invisible borders, cards that blend into the background. The developer added dark:bg-gray-900 to a few elements and called it done.
That's not dark mode. That's a color swap.
The Inversion Trap
Light mode creates depth with shadows and borders on a white background. Cards float above the page because they're white on off-white with a subtle shadow.
If you invert this mechanically:
- White (
100%lightness) becomes near-black (7%) - Off-white (
97%) becomes slightly-less-black (3%) - Shadows are invisible on dark backgrounds
- Cards disappear into the page
You've lost your entire depth hierarchy. Everything looks flat.
How Dark Mode Actually Works
Light mode and dark mode use fundamentally different strategies to create visual depth:
Light mode: depth from borders and shadows. Surfaces are similar lightness values — the shadow and border do the separation work.
Dark mode: depth from lightness stepping. Each layer is slightly lighter than the one below it. Shadows are nearly invisible and that's fine — lightness does the job.
A proper dark mode depth hierarchy:
Page background 7% lightness (deepest)
Sidebar 9% (slightly lifted)
Cards 10% (float above page)
Muted surfaces 14% (secondary content areas)
Borders 16% (lightest — visible separators)
Each step is 2-3% lightness. Enough to perceive, not enough to distract. This is the same approach GitHub, Linear, and Vercel use.
Brand Color: Don't Shift It
Your accent color should look the same in both modes. If your brand is blue, it's the same blue on white and on dark gray. Shifting it creates a disjointed experience — users feel like they're using two different products.
What does change: the *muted* variant of your brand. In light mode, a brand tint at 88% lightness works as a subtle hover state. In dark mode, that same 88% lightness is blinding. You need a dark brand-muted at 12% lightness instead.
Foreground Colors Need Tuning
Don't use pure white (#fff) for dark mode text. It's too harsh against dark backgrounds and causes eye strain in low-light environments.
Off-white with a slight cool or warm tint reads better:
hsl(210 20% 94%)— cool off-white (good for blue-tinted dark themes)hsl(40 10% 92%)— warm off-white (good for warm or neutral themes)
Similarly, muted text in dark mode needs to be lighter than you'd expect. 58% lightness reads as "secondary" on a 7% background. Going lower makes it unreadable.
Status Colors Need Separate Values
A red at 55% lightness looks alarming on a white background — which is correct for a destructive action. On a dark background, that same red glows and dominates the visual hierarchy.
Dark mode status colors should be slightly darker (lower lightness) to maintain the same visual weight:
| Color | Light mode | Dark mode |
|---|---|---|
| Destructive | `0 72% 55%` | `0 55% 45%` |
| Success | `142 72% 29%` | `142 72% 40%` |
| Warning | `38 92% 50%` | `38 92% 55%` |
The hue and saturation stay similar. Only lightness adjusts.
The next-themes Setup
For Next.js apps, next-themes handles system preference detection and toggle state:
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
Three settings that matter:
attribute="class"— applies.darkto<html>, which Tailwind usesdefaultTheme="system"— respects OS preference out of the boxdisableTransitionOnChange— prevents a flash of transitioning colors on toggle
The CSS Variable Approach
Define all colors as CSS variables with both :root and .dark variants:
:root {
--background: 40 20% 97%;
--card: 40 15% 100%;
--border: 40 10% 85%;
}
.dark {
--background: 222 14% 7%;
--card: 222 14% 10%;
--border: 222 14% 16%;
}
Map these to Tailwind utility classes and every component automatically supports both modes. No conditional dark: prefixes scattered through your codebase.
Testing Checklist
Before shipping dark mode:
- Toggle between modes — no flash, no layout shift
- Cards are visually distinct from the page background in both modes
- Text meets 4.5:1 contrast ratio (use Chrome DevTools contrast checker)
- Status colors (red, green, yellow) are legible in both modes
- Brand accent looks consistent across both themes
- Shadows are intentionally subtle in dark mode (not accidentally invisible)
- Images and illustrations don't look wrong on dark backgrounds
Dark mode isn't a feature toggle. It's a parallel design system. The best implementations are invisible — users don't notice the quality, they just feel comfortable using the product in any lighting.