Writing

Implementing Zero-Flash System-Aware Dark Mode with Tailwind CSS v4 and React 19

Eliminating FOUT, synchronizing OS color schemes live, and managing CSS design tokens without layout shift.

5 min read892 viewsBy Ulrik Matemu
Tailwind CSSNext.jsReactDark ModePerformance

The Anatomy of a Theme Flash (FOUT)

A common flaw in modern web applications is the flash of unstyled or mismatched theme (FOUT) upon initial page load. When a user with dark OS preferences visits a site, standard client-side hydration causes a bright white screen to flash for 100-300 milliseconds before React initializes and toggles a CSS class.

In this article, we examine how to achieve deterministic, zero-flash theme rendering in Next.js App Router while fully honoring system preferences and user manual overrides.

0 msInitial Paint FOUT
0Hydration Warnings
0Dependencies Added

Phase 1: Dynamic Tokens with Tailwind CSS v4

With Tailwind CSS v4, theme tokens can map directly to standard CSS variables rather than hardcoded hex values. By declaring semantic color tokens inside `@theme` that point to `--color-*` variables, every utility class (like `bg-stone` or `text-ink`) responds dynamically to `.dark` or `.light` class changes.

globals.css
@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-stone: var(--color-stone);
  --color-ink: var(--color-ink);
  --color-accent: var(--color-accent);
}

:root {
  --color-stone: #E9E7E2;
  --color-ink: #1A1917;
  --color-accent: #24363B;
}

.dark {
  --color-stone: #141311;
  --color-ink: #EDEAE4;
  --color-accent: #79B4B0;
}

Dynamic theme token definitions in Tailwind CSS v4 enabling instant re-skinning.

Phase 2: Pre-Paint Execution with Synchronous Head Script

To guarantee zero flash, an inline JavaScript snippet must execute synchronously inside `<head>` prior to browser paint. The script checks `localStorage` first, and if unset, falls back to `window.matchMedia('(prefers-color-scheme: dark)')`.

Applying `suppressHydrationWarning` to `<html>` prevents React from complaining about the runtime class attributes.

anti-flash.js
(function() {
  try {
    var stored = localStorage.getItem('portfolio-theme');
    var isDark = stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches);
    if (isDark) {
      document.documentElement.classList.add('dark');
      document.documentElement.classList.remove('light');
    } else if (stored === 'light') {
      document.documentElement.classList.add('light');
      document.documentElement.classList.remove('dark');
    }
  } catch (e) {}
})();

Synchronous pre-paint execution block preventing any visible theme flicker.

Phase 3: React 19 State Management via useSyncExternalStore

Rather than triggering cascading renders with `useEffect` and `useState`, modern React applications should subscribe to browser storage and media queries using `useSyncExternalStore`.

This approach guarantees tearing-free reads across tabs and synchronizes live when users switch their OS dark mode setting in real time.