Most dark modes fail the same way: someone flips white to black, black to white, and ships it. The result is harsh, flat, and hard to read. Good dark mode is a second palette with its own rules — and once you know the three rules, it takes an afternoon, not a rewrite.
Dark is its own palette
Pure black backgrounds with pure white text create too much contrast — text seems to vibrate. Start from near-black (#0a0a0a) and off-white (#ededed) instead.
Depth also flips. In light mode, shadows separate a card from the page. On a dark background shadows are invisible, so elevation comes from lightness: the higher a surface sits, the slightly lighter it gets. Saturated brand colors need the opposite treatment — desaturate and brighten them a step, or they glow like neon.
Tokens do the switching
Never write dark: against raw hex values all over your app. Define semantic tokens once, and let components reference meaning instead of color — the same approach shadcn/ui uses:
:root {
--background: 0 0% 100%;
--foreground: 240 10% 4%;
--card: 0 0% 100%;
--border: 240 6% 90%;
}
.dark {
--background: 240 10% 4%; /* near-black, not #000 */
--foreground: 0 0% 93%; /* off-white, not #fff */
--card: 240 6% 8%; /* lighter = elevated */
--border: 240 4% 16%;
}Components now say bg-background or border-border and never know which theme is active. Adding a third theme later costs one CSS block, not a codebase sweep.
Kill the flash
The classic bug: the page loads light, then snaps dark. That happens when the theme is applied in JavaScript after hydration. Use next-themes with attribute="class" — it injects a tiny inline script that sets .dark on <html> before first paint, and it respects the system preference by default.
Start here
Convert your five core tokens first — background, foreground, card, border, primary — and ship. Every component you build on top of them inherits dark mode for free. That’s the whole trick: design the palette once, then never think about it in component code again.