The flash of wrong theme (FOWT) is the brief moment where a page loads in light mode for a user who prefers dark, or vice versa, before JavaScript applies the correct theme. It happens because the default page color scheme renders before the JavaScript that reads localStorage and applies the user's preference has a chance to run.
For many applications, FOWT is the most visible dark mode bug and the hardest to eliminate without understanding what causes it. This article explains the root cause and walks through the specific fix for client-rendered applications, server-rendered applications, and Next.js specifically.
Why the Flash Happens
JavaScript-first dark mode implementations typically follow this sequence:
- Browser receives HTML and begins parsing
- Browser begins downloading and evaluating scripts (deferred or at end of
<body>) - CSS renders the page using
:rootdefault values (light mode) - JavaScript runs, reads
localStorage.getItem('theme'), appliesdata-theme="dark" - Page re-renders in dark mode
The user sees step 3 -- the light mode render -- before step 4 applies the correct theme. On a fast device, this flash is brief but still jarring. On a slow connection or low-end device, it lasts long enough to be obviously wrong.
The Fix: Block Rendering Until the Theme Is Applied
The only reliable fix is to apply the theme before the browser makes its first paint. This means running a small inline script in the <head> of the document -- specifically, before any stylesheets have finished loading.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<!-- This script runs before any CSS is applied -->
<script>
(function() {
var stored = localStorage.getItem('theme');
var preferred = stored
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', preferred);
})();
</script>
<link rel="stylesheet" href="/styles.css">
</head>
This is a blocking script -- it prevents rendering until it executes. Normally, blocking scripts in the <head> are a performance anti-pattern because they delay the page from rendering. Here, that blocking behavior is what we need. The script is small enough (under 200 bytes minified) that the blocking cost is negligible, and the tradeoff is correct: a brief delay on all page loads is better than a visible theme flash on every load for users who have set a preference.
The inline script must be placed before your stylesheet <link> tags. If the stylesheet loads and paints before the script runs, the flash still occurs.
The window.matchMedia API reads the prefers-color-scheme media query value from the OS, which is used as the fallback when no stored preference exists in localStorage.
Preventing Theme Transition on Load
If you have added CSS transitions to smooth theme switching, those same transitions will animate the initial theme application on page load -- which looks like a flash even when the theme is technically applied before first paint. Users see the correct theme animate in from the wrong one.
The fix is to suppress transitions until after the initial load:
/* Start with transitions disabled */
html.no-transition * {
transition: none !important;
}
(function() {
var stored = localStorage.getItem('theme');
var preferred = stored
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', preferred);
document.documentElement.classList.add('no-transition');
})();
// After the DOM is ready, remove the no-transition class
document.addEventListener('DOMContentLoaded', function() {
requestAnimationFrame(function() {
document.documentElement.classList.remove('no-transition');
});
});
The requestAnimationFrame ensures that the class removal happens after the first frame has been committed, which guarantees transitions are not active during the initial render.
Server-Rendered Applications: Setting the Theme Before HTML Delivery
In a server-rendered application (PHP, Django, Rails, etc.), you can set the data-theme attribute server-side if the theme preference is stored in a cookie rather than localStorage. The cookie is sent with the request, the server reads it, and the HTML is delivered with data-theme="dark" already present on the <html> element.
Set-Cookie: theme=dark; SameSite=Strict; Path=/; Max-Age=31536000
On the server side, read the cookie and render the HTML accordingly:
<!-- PHP example -->
<?php $theme = $_COOKIE['theme'] ?? 'light'; ?>
<html data-theme="<?= htmlspecialchars($theme) ?>" lang="en">
This eliminates the flash entirely because the HTML arrives from the server already configured with the correct attribute. No client-side JavaScript is required for the initial render.
The tradeoff: the client-side localStorage approach and the server-side cookie approach are mutually exclusive in their simplest implementations. If you are migrating from localStorage to cookie-based persistence, you need a migration path for existing users whose preferences are in localStorage.
Next.js and React Server Components
In Next.js, the standard approach is to use a combination of the <Script> component with strategy="beforeInteractive" and a server-side theme resolution via cookies.
For the client-side fallback, add a beforeInteractive script in your root layout.tsx:
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<Script id="theme-init" strategy="beforeInteractive">
{`
(function() {
var stored = localStorage.getItem('theme');
var preferred = stored
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', preferred);
})();
`}
</Script>
</head>
<body>{children}</body>
</html>
);
}
The beforeInteractive strategy renders the script inline in the <head> before any React hydration occurs, which places it in the same position as the manual inline script approach described above.
For server-side resolution with cookies in Next.js App Router, read the cookie in the root layout Server Component and set the attribute on the <html> element before delivery:
import { cookies } from 'next/headers';
export default function RootLayout({ children }) {
const theme = cookies().get('theme')?.value ?? 'light';
return (
<html lang="en" data-theme={theme}>
<body>{children}</body>
</html>
);
}
This delivers HTML with the correct theme already applied, eliminating the flash entirely for server-rendered pages.
Pure CSS: No Flash by Design
If you use only prefers-color-scheme and CSS custom property overrides without any JavaScript, there is no flash. The CSS media query applies before the page paints, and the correct theme tokens are in place from the first render.
The limitation is that you cannot offer a manual toggle -- users who want dark mode on a light-OS device, or light mode on a dark-OS device, have no option. For applications where respecting the system default is sufficient, the pure CSS approach is the simplest path with zero flash risk.
"The flash-of-wrong-theme bug is one of the first things we look for in a dark mode code review. It is almost always solvable without significant refactoring -- the solution is always some variant of 'run the theme script earlier.' Teams are often surprised that the fix is adding a blocking script, because blocking scripts are normally what you remove in performance optimization work. The blocking here is intentional and the performance cost is negligible." -- Dennis Traina, founder of 137Foundry (view services)
Verifying Your Fix Works
After implementing one of these approaches, verify the fix with browser DevTools:
- In Chrome DevTools, open the Application tab and clear the site's localStorage.
- Set a
themevalue in localStorage (localStorage.setItem('theme', 'dark')). - Reload the page with the DevTools Network tab open and throttling set to "Slow 3G."
- Observe the page load. If a light-mode flash precedes the dark theme, the fix is not in the right position.
A correct implementation shows no visible theme transition on load -- the page arrives in the stored theme without any intermediate state.
The full dark mode implementation context -- CSS custom properties, the localStorage toggle, and the system preference detection -- is covered in How to Add Dark Mode to a Web Application. The FOWT fix described here slots into that broader implementation as the final piece that eliminates the last visible artifact.
For projects where dark mode is part of a larger front-end architecture scope, 137Foundry development services include front-end code review and implementation consulting. FOWT elimination is a consistent item in 137Foundry's front-end review checklist for applications with user-controlled theme switching.
Top comments (0)