Persisting User Theme Choice Without a Flash on Reload
Part of Runtime Theme Switching. This page walks through every step required to store a user’s explicit theme selection in localStorage and reapply it before the browser paints a single pixel — so the page never shows a flash of the wrong theme on reload.
data-theme during HTML parsing, so first paint always renders the correct theme.Prerequisites
Before following these steps, confirm:
- Your theme is driven by a
data-themeattribute on<html>(e.g.,data-theme="dark"), not by toggling a class on<body>. Attribute-driven theming is easier to target from a minimal script without touching class lists. - Your CSS custom properties are defined under
[data-theme="light"]and[data-theme="dark"]selectors at the:rootlevel, as described in the runtime theme switching architecture. - You have a theme toggle in your UI that already writes to
localStoragewhen the user makes a choice. - Your build tool does not inject scripts into
<head>between your critical CSS link and the body — that ordering matters. - Browser targets: all modern evergreen browsers. The script uses ES5 syntax deliberately to avoid any transpilation dependency in this critical path.
Step 1: Define your CSS theme selectors
Before writing a single line of JavaScript, the CSS must be structured so the data-theme attribute alone is sufficient to switch every visible color. The inline script is worthless if the CSS ignores it.
/* tokens.css — included as a stylesheet link in <head> */
:root {
/* Light theme defaults — applied when no data-theme or data-theme="light" */
--color-bg: #ffffff;
--color-text: #0f172a;
--color-surface: #f1f5f9;
--color-border: #e2e8f0;
--color-action: #2563eb;
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-surface: #1e293b;
--color-border: #334155;
--color-action: #60a5fa;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
}
Why this works. Setting defaults on :root means that if the inline script fails (private browsing, sandbox restrictions, JS disabled), the page still renders a coherent light theme. The [data-theme="dark"] block overrides only what the script has already set, so there is no specificity fight and no flash window.
Step 2: Add the color-scheme meta tag
Place this in <head> before your stylesheet link. It tells the browser’s built-in UA styles and form controls which color mode to render in, independently of your custom properties.
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<!-- your stylesheet link comes after this -->
<link rel="stylesheet" href="/tokens.css">
</head>
Why this works. Without color-scheme, scrollbars, input backgrounds, and <select> elements render in light mode even when your custom properties say dark. The light dark value signals that your page actively handles both; the browser picks the matching UA style based on data-theme once you also wire color-scheme in CSS (Step 3).
Step 3: Mirror color-scheme in CSS
Alongside content="light dark" in the meta tag, declare it as a CSS property so it cascades correctly into shadow DOM and iframes.
/* add to tokens.css, inside the same data-theme selectors */
:root {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
Why this works. The CSS color-scheme property is what actually communicates the preference to the rendering engine for UA-controlled components (scrollbars, form inputs). The meta tag is the pre-CSS hint for the very first paint; the CSS property takes over for everything subsequently rendered. Keeping both in sync prevents mismatches where scrollbars flip mid-render.
Step 4: Write the render-blocking inline script
This is the core fix. Place this <script> block in <head>, after your stylesheet <link> but before </head>. It must be inline and synchronous — not defer, not type="module", not async, and not a src= reference to an external file.
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<link rel="stylesheet" href="/tokens.css">
<script>
(function () {
try {
var stored = localStorage.getItem('user-theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var resolved = stored === 'dark' || stored === 'light'
? stored
: (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) {
// localStorage blocked (private browsing, sandboxed iframe, or cookie policy).
// CSS :root defaults handle the fallback silently.
}
}());
</script>
</head>
Why this works. The HTML parser executes inline <script> tags synchronously — it pauses parsing, runs the script, then continues. This means data-theme is set on <html> before the parser encounters <body>, before layout, and before first paint. The browser never has an opportunity to paint the wrong theme. An external src= script would require a network round-trip first; defer and type="module" run after the document has been parsed and the first paint may already be committed.
The try/catch is not optional. In Safari’s private browsing mode and in sandboxed iframes, localStorage access throws a SecurityError. Without the catch, the script crashes before setting data-theme and you get exactly the flash you were trying to prevent.
Step 5: Implement the toggle and persist the choice
The UI toggle writes to localStorage and updates data-theme in a single synchronous call. Keep this in your main JavaScript bundle — it does not need to be in <head>.
// theme-toggle.js — loaded as a normal deferred/module script
const STORAGE_KEY = 'user-theme';
function getStoredTheme() {
try {
return localStorage.getItem(STORAGE_KEY);
} catch (e) {
return null;
}
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch (e) {
// Fail silently; the visual change still takes effect for this session.
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
}
// Wire up your toggle button
const btn = document.getElementById('theme-toggle-btn');
if (btn) {
btn.addEventListener('click', toggleTheme);
// Keep the button's accessible state in sync on load
const stored = getStoredTheme();
if (stored) {
btn.setAttribute('aria-pressed', stored === 'dark' ? 'true' : 'false');
}
}
Why this works. Writing to localStorage is synchronous, so the stored value is guaranteed to be present before any subsequent page load executes the inline head script from Step 4. The toggle also updates data-theme immediately, so CSS custom properties re-cascade instantly without any layout recalculation beyond color repaints.
Step 6: Sync the toggle button’s visual state on initial load
The inline head script applies the theme before the DOM is interactive. Your toggle button may not yet exist at that point — it is rendered later when the <body> is parsed. Read data-theme once the DOM is ready and synchronize the button’s visual and ARIA state.
// Runs after DOMContentLoaded, or place at bottom of <body>
document.addEventListener('DOMContentLoaded', function () {
var theme = document.documentElement.getAttribute('data-theme');
var btn = document.getElementById('theme-toggle-btn');
if (!btn) return;
btn.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
btn.setAttribute('aria-label',
theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
);
});
Why this works. Without this step, a user who reloads with dark mode active will see the correct theme (the inline script handled that) but the toggle button shows as “light” because it initialized before reading the DOM attribute. Syncing on DOMContentLoaded closes that discrepancy without any flicker because the theme itself was already correct from Step 4.
Step 7: Handle the storage event for multi-tab consistency
When a user switches theme in one tab, other open tabs should update immediately. The storage event fires in every tab except the one that wrote the change.
window.addEventListener('storage', function (event) {
if (event.key !== 'user-theme') return;
if (event.newValue !== 'dark' && event.newValue !== 'light') return;
document.documentElement.setAttribute('data-theme', event.newValue);
var btn = document.getElementById('theme-toggle-btn');
if (btn) {
btn.setAttribute('aria-pressed', event.newValue === 'dark' ? 'true' : 'false');
}
});
Why this works. The storage event is only fired cross-tab, not in the originating tab, so you will not get a double-apply loop. Validating event.newValue before applying it prevents malicious or corrupted storage values from breaking the UI.
Verification
Manual check with CPU throttling
- Open DevTools → Performance → enable 6x CPU slowdown.
- Hard-reload with dark mode set in
localStorage. - Record the timeline. Confirm that
data-theme="dark"appears on<html>in the Timings row before the first Paint event. No white/light flash should be visible in the filmstrip.
Lighthouse regression check
Run Lighthouse before and after adding the inline script:
npx lighthouse http://localhost:3000 --only-categories=performance --output=json \
| jq '.audits["render-blocking-resources"].score'
A correctly sized inline script (under ~1 KB) does not count as a render-blocking resource in Lighthouse’s scoring because Lighthouse treats inline scripts as part of the HTML payload, not a separate network request. If your score drops, the script grew too large — move everything except the localStorage read and setAttribute call into your deferred bundle.
Playwright automated check
// e2e/theme-persist.spec.js
import { test, expect } from '@playwright/test';
test('no flash on reload with stored dark theme', async ({ page, context }) => {
// Set localStorage before navigating
await context.addInitScript(() => {
localStorage.setItem('user-theme', 'dark');
});
await page.goto('/');
// data-theme must be set before any paint
const theme = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(theme).toBe('dark');
// Confirm CSS variable resolved to dark surface
const bg = await page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue('--color-bg')
.trim()
);
expect(bg).toBe('#0f172a');
});
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Flash persists on reload despite the inline script | Script tag has defer, async, or type="module" |
Remove those attributes; the script must be synchronous and inline |
| Flash only in private/incognito browsing | localStorage.getItem throws SecurityError without a try/catch |
Wrap the entire script body in try/catch; the CSS :root default handles the fallback |
SSR page renders with wrong data-theme in the HTML source |
Server does not read the user’s cookie or Sec-CH-Prefers-Color-Scheme header |
See setting the initial theme on the server from cookies for a cookie-based SSR approach |
| Toggle button shows wrong state after reload | Button state is initialized from markup, not from data-theme |
Read document.documentElement.getAttribute('data-theme') in DOMContentLoaded and set aria-pressed accordingly (Step 6) |
| Lighthouse flags the inline script as render-blocking | Script is larger than ~1 KB or does network I/O | Strip everything except the localStorage read, matchMedia check, and setAttribute call |
Migration Note
If you are migrating from a JavaScript framework that manages theme state in a React context, Redux slice, or Vuex module, apply this migration in phases:
Phase 1 — Parallel write. Add localStorage.setItem('user-theme', theme) inside your existing state update action. Do not remove the framework state yet. This starts populating storage for the new system without breaking anything.
Phase 2 — Add the inline script. Drop the head script from Step 4 into your HTML template. Verify in staging that the script applies the theme before any framework-controlled paint.
Phase 3 — Remove framework state. Delete the theme from your Redux/Vuex/Context store. Replace every useTheme() call with a data-theme attribute read or a CSS custom property query. The browser’s cascade now owns theme state; the framework is not needed for it.
Phase 4 — Remove framework storage listeners. Delete any useEffect or watch blocks that synced theme state to localStorage. The toggle handler from Step 5 is the sole write path.
This phased approach gives you a rollback point at each step. The inline script and the framework state can coexist temporarily because both write to the same data-theme attribute — whichever runs last wins, and the inline script always runs first.
Related
- Runtime Theme Switching — the parent section covering the full architectural scope of theme switching patterns
- Zero-JS Runtime Theme Switching with CSS Variables — a sibling approach that eliminates JavaScript at toggle time entirely, using
:has()and a hidden checkbox - Setting the Initial Theme on the Server from Cookies — the server-side complement to this technique, eliminating the flash for SSR pages by reading the user’s stored preference before the HTTP response is sent