Dark mode is not just a preference
Dark mode is one of those features that sounds trivial to implement until you actually implement it. Then you discover the flash of incorrect theme on load, the hydration mismatch warnings, the colors that look fine on your machine and terrible on someone else's. I've done it wrong at least twice. Here's what I've learned.
Why CSS custom properties are the right tool
The naive approach to dark mode is two sets of hardcoded colors — one for light, one for dark — toggled with a class or attribute. This works until you have more than ten colors and a redesign. Then it becomes a maintenance problem.
CSS custom properties (variables) solve this cleanly:
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--primary: 24 95% 53%;
}
.dark {
--background: 222 47% 8%;
--foreground: 210 40% 98%;
--primary: 24 95% 53%;
}
Every color is defined once, as a token. The .dark class overrides the tokens. Your components reference only tokens, never raw colors. When you switch theme, one class change updates every color on the page simultaneously.
The HSL format (24 95% 53% instead of #f97316) makes it easy to derive variants at the usage site with Tailwind's opacity modifier: text-primary/80, bg-primary/10. One token, infinite derived colors.
The key insight: your components should never know what the current theme is. They reference tokens; the theme decides what those tokens resolve to.
The flash problem
If you render theme preference on the client only, you get the flash of unstyled content (FOUC) — the page loads in light mode for a split second before JavaScript applies the dark class. For users who prefer dark mode, this is jarring. For users on slow connections, it's visible for several seconds.
The fix is to apply the theme class before the page renders. In Next.js, next-themes handles this with a blocking script injected into <head>:
// app/layout.tsx
import { ThemeProvider } from 'next-themes'
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
The suppressHydrationWarning on <html> is intentional — next-themes modifies the class attribute directly during SSR/hydration, which would otherwise cause a React warning. It's one of the few legitimate uses of that prop.
System preference vs user toggle
prefers-color-scheme is the media query that reflects the operating system's theme setting. You can respect it automatically:
@media (prefers-color-scheme: dark) {
:root {
/* dark tokens here */
}
}
But if you also have a user-controlled toggle, you need to decide which wins. The pattern I use: defaultTheme="system" means the OS preference is the default. Once the user explicitly toggles, their preference is stored in localStorage and takes priority over the OS setting. next-themes handles this automatically with its enableSystem option.
The colors that are harder than they look
Some things don't just invert:
- Shadows — dark shadows on a light background look natural; dark shadows on a dark background disappear. In dark mode, you often want subtle glow or elevation via background color change rather than
box-shadow. - Images with white backgrounds — product photos and screenshots that look clean on light mode look awkward on dark. Consider
mix-blend-mode: multiplyor explicit dark-mode image variants. - Semantic colors — "success green" in light mode might need to be a slightly different shade in dark mode to maintain the same perceived contrast.
These edge cases are where most dark mode implementations fall apart. The system is right; the details are wrong.
Final thoughts
Dark mode done well is invisible. The user switches it and everything just works — colors, shadows, images, focus states, all of it. Done poorly, it's a perpetual list of small visual bugs.
The investment in a proper token system up front pays for itself the first time you add a new component and it automatically supports both themes without any extra work. That's the goal: a system where dark mode is free because the foundation is correct.
And yes, light mode is for printing.