No-flash dark mode
The classic problem: the server renders one theme, then the client reads the stored preference and switches — so the page flashes the wrong theme on the first paint. This guide combines three pieces from this library to get it right:
- A blocking script (
getColorSchemeScript) that sets the theme on<html>before first paint — this is what kills the flash - A persisted choice (
useLocalStorageStateoruseCookieState) that the toggle writes - The system fallback (
usePrefersColorScheme) for users who pick “system”
The choice persists across reloads. Pair it with getColorSchemeScript so the page applies it before first paint — with no flash
1. Apply the scheme before paint
getColorSchemeScript() returns a tiny self-contained snippet. Render it as a
blocking inline script in the document <head> so it runs before anything
paints. It reads the stored choice, falls back to the OS preference, and sets a
light/dark class (and color-scheme) on <html>
// Next.js App Router — app/layout.tsx
import { getColorSchemeScript } from 'react-stateful-hooks';
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: getColorSchemeScript() }} />
</head>
<body>{children}</body>
</html>
);
}Options:
getColorSchemeScript({
key?: string; // storage key, default 'theme'
storage?: 'localStorage' | 'cookie'; // default 'localStorage'
attribute?: string; // 'class' (default) or e.g. 'data-theme'
defaultScheme?: 'light' | 'dark' | 'system'; // default 'system'
});Use
suppressHydrationWarningon<html>because the script mutates it before React hydrates
2. The toggle
The toggle just reads and writes the same key. Use the same key,
storage, and attribute you passed to getColorSchemeScript
'use client';
import { useEffect } from 'react';
import {
useLocalStorageState,
usePrefersColorScheme,
} from 'react-stateful-hooks';
type Choice = 'light' | 'dark' | 'system';
export function ThemeToggle() {
const [choice, setChoice] = useLocalStorageState<Choice>('theme', 'system');
const system = usePrefersColorScheme();
const scheme = choice === 'system' ? system : choice;
// Keep <html> in sync after the first paint (the script handled the first one)
useEffect(() => {
const el = document.documentElement;
el.classList.remove('light', 'dark');
el.classList.add(scheme);
el.style.colorScheme = scheme;
}, [scheme]);
return (
<select value={choice} onChange={(e) => setChoice(e.target.value as Choice)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}3. Style it
With the default attribute: 'class', <html> gets a light or dark class —
which is exactly what Tailwind’s class-based dark mode expects:
// tailwind.config.js
export default { darkMode: 'class' };/* or plain CSS */
:root {
--bg: white;
}
.dark {
--bg: #111;
}Cookie instead of localStorage
Want the choice readable on the server too (e.g. to render the right theme in SSR without any client JS)? Store it in a cookie and tell both the script and the toggle:
// in <head>
getColorSchemeScript({ storage: 'cookie' });
// in the toggle
const [choice, setChoice] = useCookieState<Choice>('theme', 'system', {
maxAge: 60 * 60 * 24 * 365,
});Cookies are sent with every request, so you can also read theme on the server
and set the class during SSR — see useCookieState