The Hidden Traps of Dark Mode Implementation
Adding a toggle button is easy. Building dark mode properly is a different story
The Toggle Button Takes 30 Minutes
I decided to add dark mode after opening my blog at night and having my eyes assaulted. "Just add a toggle button," I thought casually. Swap a few CSS variables, add a button, done.
The toggle button itself really does take 30 minutes. But building dark mode "properly" took a week. Here are the traps I stepped on along the way.
Trap 1: The Flash Problem
The first and most painful trap was FOUC (Flash of Unstyled Content). I implemented dark mode in Next.js, and every page refresh would produce a white flash before switching to the dark theme.
The cause is straightforward. During server rendering, the user's theme preference is unknown, so the server sends HTML with the default (light) theme. After the client-side JavaScript executes, it reads the setting from localStorage and switches the theme. The gap between those two moments causes the flash.
The fix is adding an inline script in the <head> that adds a class to the <html> tag before JavaScript runs. This script is render-blocking, but it's tiny enough that the performance impact is negligible.
Trap 2: System Preference Sync
"Follow the system setting" is a surprisingly complex requirement. You can detect the system preference with the prefers-color-scheme media query, but factoring in manual user override creates three states: light, dark, and system.
If the user manually selects dark, it should stay dark even when the system is set to light. If they select "system," the theme should follow whenever the system setting changes. Implementing this logic with useSyncExternalStore took considerable effort.
Trap 3: Choosing the Right Colors
You might think "just make the background black." But pure black (#000000) background with pure white (#FFFFFF) text creates excessive contrast that's actually harder on the eyes.
Dark mode backgrounds should be slightly lighter than pure black (#121212-#1a1a1a), and text should be slightly dimmed from pure white (#e0e0e0-#ebebeb) for comfortable reading. Material Design guidelines emphasize this same principle.
Dark mode isn't "inverting colors" -- it's "redesigning for a dark environment."
Trap 4: Images and Shadows
White-background images are blinding in dark mode. For screenshots and diagrams with white backgrounds, adding slight transparency or a rounded border that blends with the dark background helps.
Shadows also need rethinking in dark mode. The box-shadow you use in light mode is invisible on a dark background -- obviously, since it's a shadow on a dark surface. Instead, use slightly lighter borders or express depth through background-color differences rather than shadows.
Trap 5: Third-Party Widgets
My blog has a Giscus comment widget that runs as a separate iframe. When the main site's theme changes, Giscus inside the iframe stays unchanged. You need to dynamically pass the theme to Giscus, which requires postMessage since it's cross-iframe communication.
Code highlighting is the same story. You need both light and dark Shiki themes prepared, switching between them via CSS variables. I didn't know this at first, and my code blocks were glowing beacons in dark mode.
Testing Approach
Testing dark mode by just clicking the toggle isn't enough. I made a checklist: no flash on page refresh, follows system setting changes, text is readable on every page, images and code blocks look appropriate, third-party widgets stay in sync.
Running this checklist on each page reveals issues you'd otherwise miss.
Wrapping Up
Dark mode looks like an "easy feature," but doing it right requires CSS, JavaScript, UX design, and third-party integration -- it's a full-stack effort. But once you implement it properly, user satisfaction clearly improves. If someone reading my blog at night has more comfortable eyes, the week-long investment was well worth it.