Dark Mode Done Right: CSS Variables + Tailwind, No Flash of Wrong Theme
The 'flash of wrong theme' bug — light mode briefly showing before snapping to dark — has a specific, fixable cause, and custom properties are the fix.
The classic dark-mode bug: a page loads, briefly flashes light mode, then snaps to dark a moment later. It has a specific, mechanical cause, and understanding it is what makes the fix obvious rather than a random trial of workarounds.
The actual cause
The browser paints the very first frame using whatever CSS it has at that moment — before any JavaScript has run. If dark mode is applied by a JavaScript check that runs AFTER the page loads (reading a saved preference from `localStorage`, then adding a `dark` class to `<html>`), there's a real, visible gap between "page painted with default light styles" and "JS ran and switched it to dark" — that gap is the flash.
The fix: let CSS resolve the theme before JS ever runs
:root { --bg: #ffffff; --text: #111111; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #0b0b0d; --text: #e9e9ed; }
}
:root[data-theme="dark"] { --bg: #0b0b0d; --text: #e9e9ed; }
/* Both the OS-level preference AND an explicit user override are handled
in pure CSS — the browser resolves the correct value before first
paint, with zero JavaScript needed for the common case at all. */`prefers-color-scheme` is a media query the browser evaluates as part of normal CSS resolution, before paint — no JavaScript required for a visitor whose preference matches their OS setting, which is the majority case. JavaScript is only needed for the *override* case (an explicit in-page toggle) — and even then, the fix is to set the `data-theme` attribute as early as possible (a tiny, blocking inline script in `<head>`, exactly like this site's own `no-js` → `js` class toggle for scroll reveals) so it happens before the first paint, not after.
Why this is the same underlying lesson as the reveal-animation fix elsewhere on this site
Both bugs share one root cause: something visually important was decided by JavaScript running AFTER the browser's first paint, instead of being resolvable from CSS (or a tiny blocking script) BEFORE it. Once that's the actual mental model — "what does the very first painted frame look like, before any JS has had a chance to run" — an entire category of flash-of-wrong-content bugs stops being mysterious.
How planning decisions like this one get made before any code exists.