Theming and Dark Mode Without the Flash: A Systems Problem in CSS Clothing

Jul 31, 2026
11 min read
Theming and Dark Mode Without the Flash: A Systems Problem in CSS Clothing

The last article argued that a lot of React "optimization" is really architecture damage in disguise. Theming is a great place to keep that lens on, because it's the feature that looks the most like a trivial CSS toggle and is actually a small systems-design problem — one where the naive version fails in three specific, visible ways.

You start simple. A dark class on the body, some overrides, a bit of React state to flip it. Ship it. Then the reports come in. The page flashes white before going dark on every load. A few components didn't get the memo and stayed light. And your theme toggle mysteriously re-renders half the app. None of these is a CSS bug exactly — they're all consequences of putting theming in the wrong layer.

Tokens are the foundation, not the paint

The first mistake is letting every component decide its own colors. color: #1a1a1a here, background: #fff there, a slightly different grey in a third place. Now "dark mode" means hunting down every hardcoded color in the codebase, and "the greys drifted" is inevitable because there was never one grey to begin with.

The fix is design tokens expressed as CSS custom properties: one place that owns the actual color decisions, and components that only ever refer to them semantically.

:root { /* primitive palette — raw values, referenced by nobody directly */ --gray-50: #f9fafb; --gray-900: #111827; --green-500: #22c55e; /* semantic tokens — what components actually use */ --color-surface: var(--gray-50); --color-text: var(--gray-900); --color-accent: var(--green-500); } [data-theme='dark'] { --color-surface: var(--gray-900); --color-text: var(--gray-50); --color-accent: var(--green-500); /* accent survives the theme */ }

The two-layer split matters more than it looks. Primitives (--gray-900) are the paint box; semantic tokens (--color-surface) are the meaning. Components consume only the semantic layer — background: var(--color-surface) — so they never know or care what actual color that is in a given theme. Switching themes is now just swapping what the semantic tokens point to. And critically, a component that says var(--color-surface) cannot drift, because there's exactly one definition of surface per theme. This is the same "one source of truth, referenced by many" idea from data normalization, applied to color.

The switch doesn't belong in React state

Here's the part that surprises people, and it ties straight back to the last article. Flipping the theme should not be a React re-render at all. You change one attribute on the root element — data-theme="dark" — and the CSS cascade does the entire rest of the work, instantly, for every element on the page, with zero components re-rendering.

function applyTheme(theme: 'light' | 'dark') { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); // persist, as a side effect }

Contrast that with the common mistake: putting the theme in a React context that wraps the whole app, so that toggling it re-renders every consumer to pass down new colors. That's taking a job CSS does for free — cascading a variable change to the whole tree — and turning it into a render storm, the exact anti-pattern from the re-render article. Let CSS variables do what they're built for. You can still keep a small piece of React state for the toggle's UI (which icon to show), but the actual theming mechanism is a DOM attribute and a cascade, not a re-render.

The flash is a rendering-order problem

Now the ugliest one: the flash of the wrong theme (FOUC). On a statically exported, server-rendered site — which is exactly this stack — the server has no idea what theme the user picked. It renders the default, ships the HTML, the browser paints it, and only then does your React code boot, read localStorage, and correct the theme. That gap between first paint and correction is the flash, and no amount of React can remove it, because React runs too late by definition.

The fix has to happen before first paint, which means a tiny blocking script in the document <head>, before any content renders:

// injected into <head>, runs synchronously before the body paints const themeScript = ` (function () { try { var stored = localStorage.getItem('theme'); var theme = stored || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', theme); } catch (e) {} })(); `; // in the App Router root layout export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <head> <script dangerouslySetInnerHTML={{ __html: themeScript }} /> </head> <body>{children}</body> </html> ); }

It feels a little dirty to hand-inject a script, but it's the correct tool: it sets data-theme on <html> synchronously, before the browser paints a single pixel, so the very first frame is already the right theme. This is also the disciplined version of the localStorage lesson — read persisted state in the right place at the right time, not during React's render where it causes hydration mismatches. Note suppressHydrationWarning on <html>: the attribute the script sets won't match the server's markup, and that's expected, so we tell React not to complain about this one specific, intentional divergence.

Respect the system, but let people override

A complete theming system has three inputs, in priority order: the user's explicit choice, then the OS preference, then a default. prefers-color-scheme gives you the OS signal, and matchMedia lets you react to it changing live (someone flips their laptop to dark at sunset):

const media = matchMedia('(prefers-color-scheme: dark)'); media.addEventListener('change', (e) => { // only follow the system if the user hasn't set an explicit preference if (!localStorage.getItem('theme')) { applyTheme(e.matches ? 'dark' : 'light'); } });

The rule that keeps this sane: an explicit user choice always wins and always persists; the system preference is only the fallback when the user hasn't decided. Conflating those two — following the OS even after the user picked something — is how you get the maddening bug where a user's chosen theme keeps getting overridden.

The reframe

Theming teaches the same lesson this stretch of the series keeps circling: the thing that looks like a surface detail is usually a boundary decision. Where do color decisions live? (In tokens, once.) What actually performs the switch? (A DOM attribute and the cascade, not React.) When does the theme get resolved? (Before first paint, not after hydration.) Get those three boundaries right and dark mode is almost anticlimactic — a few dozen lines, no flash, no drift, no render storm. Get them wrong and you're chasing flashes and mismatched components forever, patching symptoms of a structure that put theming in the wrong place.

Next, the series stays on architectural UI but moves from how the app looks to how power users drive it. The command palette — that Cmd+K menu — looks like a search modal and is really a command registry: one source of truth for everything the app can do, feeding the palette, the menus, and the keyboard shortcuts at once. That's where we go.

If your app has dark mode, do the two-second test: hard-refresh it a dozen times on a throttled connection and watch the first frame. If you catch a flash of the wrong theme, your theming is resolving too late — and I'd love to hear whether it was the flash, the drift, or the render storm that got you first.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.