The Journal

Interface Craft3 min read

Dark Mode Done Right: A Practical Guide for Web Apps

Dark mode is more than inverting colors. Here's how to build dark mode that actually looks good and doesn't break your UI.

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:

globals.css
: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.

Frequently asked questions

How do I build dark mode properly in a web app?
Treat dark mode as a separate palette with its own rules, not inverted colors. Use near-black backgrounds and off-white text, drive elevation with lightness instead of shadows, define semantic tokens once, and apply the theme before first paint to avoid a flash.
Why does my page flash light before switching to dark mode?
The flash happens when the theme is applied in JavaScript after hydration. Use next-themes with its class attribute mode, which injects a tiny inline script that sets .dark on the html element before first paint and respects the system preference by default.
Should dark mode use pure black and pure white?
No. Pure black backgrounds with pure white text create too much contrast and the text appears to vibrate. Start from near-black around #0a0a0a and off-white around #ededed, and desaturate saturated brand colors a step so they do not glow like neon.