ehsan.blog
~/blog/how-to-force-dark-mode-with-no-flash — zsh
cat how-to-force-dark-mode-with-no-flash.md

How to force dark mode with zero flash of the wrong theme

·4 min read

You’ve seen it: a dark-themed site loads and for a split second flashes white before snapping to dark. It’s jarring, it looks broken, and it happens on a lot of sites that “do dark mode properly” with JavaScript. My portfolio is dark-only and never flashes. The trick is almost embarrassingly simple — but it depends on understanding when different pieces of your page run.

Why JavaScript theming flashes

The usual dark-mode setup reads a preference and toggles a CSS class, something like:

js
// The flash-prone pattern
const theme = localStorage.getItem("theme")
if (theme === "dark") document.documentElement.classList.add("dark")

The problem is timing. The browser parses your HTML top to bottom and paints what it has before your JavaScript bundle has downloaded, parsed, and executed. So the sequence is:

sequenceDiagram
  participant B as Browser
  B->>B: Parse HTML (no dark class yet)
  B->>B: Paint — LIGHT theme shown
  B->>B: Download + run JS
  B->>B: Add "dark" class
  B->>B: Repaint — DARK theme

That first paint is the flash. The HTML on disk didn’t know it was supposed to be dark, so the browser guessed light. localStorage makes it worse, because you can only read localStorage from JavaScript — which by definition runs after that first paint.

The fix: put the truth in the HTML

If the theme is a property of the HTML file itself, there’s nothing to wait for. My site is dark-only, so I bake the class straight into the root element:

html
<!-- index.html -->
<html lang="en" class="dark">

That’s it. The static HTML already carries class="dark", so the very first paint is dark. There is no window in which the wrong theme can appear, because JavaScript is never in the critical path for the theme.

This matters even more on a prerendered/SSG site like mine. The app is statically generated with vite-react-ssg — the HTML you receive is already fully built. If I toggled the class in JavaScript instead, the prerendered HTML would ship as light and then flip on the client. By putting class="dark" in index.html, the generated output is correct at rest.

Keep the JS entry point out of it

Because the theme lives in the HTML, my app entry does not touch the theme at load time. It documents the decision instead of re-implementing it:

tsx
// src/main.tsx
// Prerendered at build time and hydrated on the client by vite-react-ssg.
// Dark mode is applied via `class="dark"` baked into index.html so it survives
// static generation and avoids a flash of the wrong theme before hydration.
export const createRoot = ViteReactSSG(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>,
)

There’s an important SSG rule hiding in here: don’t touch browser globals (window, document, localStorage) at module load or during render. That code runs in Node during prerendering, where those globals don’t exist — the full pattern is in keeping React code SSG-safe by avoiding browser globals. Setting the theme with a class in HTML sidesteps the whole issue — no globals, no render-time branching.

For good measure I also set the browser UI color so even the address bar starts dark:

html
<!-- index.html -->
<meta name="theme-color" content="#0a0a0a" />

What about respecting the user’s OS preference?

If you need per-user light/dark, you can’t avoid reading a preference — but you can still avoid the flash. The standard technique is a tiny inline script in the <head> (not your main bundle) that runs synchronously before first paint and sets the class. Because it’s inline and blocking, it executes before the browser paints. But if your design is committed to a single theme, as mine is, skip all of that: a static class on <html> is simpler, faster, and impossible to get wrong.

What to remember

  • The flash happens because the browser paints the HTML before your JS runs and toggles the theme class.
  • localStorage can only be read from JS, so it can never beat that first paint on its own.
  • If your site is one fixed theme, bake class="dark" into index.html — the first paint is correct and JS stays out of the critical path.
  • On SSG sites this also makes the prerendered HTML correct at rest, not just after hydration.
  • Never read window/document/localStorage at module load in a prerendered app — that code runs in Node.

Committing to one dark theme is also what let me lean into the CRT-terminal aesthetic of the portfolio.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.