Theme switching without FOUC
Use the next-themes library to build a React light and dark mode toggle that switches instantly without the flash of the wrong theme on page load.
Last lesson, “Dark mode via semantic tokens,” you styled a component to render correctly in both themes, assuming something had already put .dark on <html>. This lesson builds that something, and it’s trickier than it sounds because of one specific failure. Picture a user who keeps their machine in dark mode opening your app. For a fraction of a second they see a white flash, then the page snaps to dark. That flash is the bug this lesson exists to fix. By the end you’ll have a theme toggle that switches instantly with no flash, and you’ll be able to explain why every piece of the setup has to be there, instead of trusting that a guide told you to add it.
That flash has a name: it’s FOUC , a flash of the wrong-themed content before the right theme takes hold. Everything below this point is about preventing it.
Why the theme can’t wait for React
Section titled “Why the theme can’t wait for React”Before reaching for a tool, let’s see exactly when the flash happens, because the timing is the whole problem. Picture the naive version you’d write with only what you know so far, and watch where it breaks.
You’d put the toggle in a component, have it read the saved theme, and add .dark to <html> when the saved theme is dark. That seems reasonable. Now trace what actually happens when the page loads:
-
The server renders the page. But the server doesn’t know the user’s theme: the preference lives in the browser (in
localStorage) and in the OS setting, and the server can read neither. So it emits HTML with the default theme and no.darkclass. -
The browser receives that HTML and paints it. This is the first paint, the user’s first pixels, and it happens before any of your React code runs. So the screen shows the default light theme.
-
React loads and hydrates. Now your code runs: it reads the saved theme, sees
"dark", and adds.darkto<html>. -
The page repaints in dark. The user saw light, then dark. That gap between step 2 and step 4 is the flash.
The root cause points straight at the fix: the correction runs after the first paint. Any approach that reads the theme inside React, whether an effect or a provider that flips the class on mount, is structurally too late. By the time React is running, the wrong pixels are already on the user’s screen. You can’t fix a paint that already happened; you can only set the class before it.
So the fix has a precise shape, and we can describe it before naming any package: the class has to be written by code that runs before the body paints. The code that runs that early is a plain synchronous <script> in the <head>. The browser executes a blocking <head> script inline while it’s still parsing, before it paints the <body>. Slot a tiny script there that reads localStorage and the OS preference and writes .dark onto <html>, and the very first paint is already correct. There’s no flash, because there was never a wrong frame to flash.
The diagram below makes that concrete. Scrub through both lanes: the top lane is the naive timeline you just traced, the bottom is the fixed one. The flash lives between “HTML arrives” and “first paint,” and the inline <head> script slots into exactly that gap to close it.
The server renders both ways the same: it can’t read localStorage or the OS setting, so no theme class goes out.
The naive HTML arrives bare. The fixed HTML arrives carrying one extra thing: an inline <head> script, loaded but not yet run.
Naive lane: nothing has set the theme yet, so the browser paints the default. First paint is LIGHT, and this is the flash.
Fixed lane: the inline script runs before the body paints and writes .dark onto <html>, sliding into the gap right before the marker.
Fixed lane: now the first paint is already DARK, on the same marker where the naive lane painted light. There was never a wrong frame, so there’s no flash.
React hydrates in both lanes. The naive lane must correct the class and trigger a repaint; the fixed lane’s class is already right, so nothing moves.
This is also where a detail from earlier finally pays off. Back in “The root layout” in the previous chapter, you met suppressHydrationWarning as the targeted fix for one element whose mismatch is expected and harmless, and you saw it sit on <html> in exactly this kind of setup. You now have the timeline that explains why it’s there, and in a moment you’ll derive that prop yourself instead of taking it on faith.
One word in that timeline deserves a quick refresher, since the rest of the lesson leans on it. Hydration is React’s second pass: in the browser, it walks the same component tree the server already rendered, adopts the existing HTML, and wires up event handlers. You met it in the previous chapter. The one thing that matters here is that hydration happens after the first paint, which is precisely why a React-based theme fix is too late.
next-themes: the script, packaged
Section titled “next-themes: the script, packaged”You could hand-write that inline script, but you shouldn’t. It’s fiddly to get right (reading storage, falling back to the OS preference, avoiding its own flash), and it’s already a solved problem. The standard solution on a Next.js stack is a tiny package called next-themes . It’s about 3KB, and its entire job is the thing the last section described. It does four things:
- injects that synchronous
<head>script for you, so the class is set before the first paint; - persists the user’s choice to
localStorage, so it sticks across visits; - listens to the OS
prefers-color-schemesetting, so"system"mode follows the OS live; and - exposes a React hook,
useTheme(), to read and change the theme from your components.
You install it and configure it once, and you never write the inline script yourself. That’s the part you’re paying 3KB to skip. There are only two moving parts to learn, and the rest of the lesson wires them together:
<ThemeProvider>wraps your app, holds the configuration, and injects the script.useTheme()is the hook your toggle button calls to read and set the theme.
Install it:
npm i next-themesWiring the provider into the root layout
Section titled “Wiring the provider into the root layout”There’s a placement constraint here that you already have the tools to reason about, so let’s reason about it rather than copy a recipe. <ThemeProvider> uses React context and effects, which makes it a Client Component. But app/layout.tsx is the root layout: it owns the <html> and <body> tags, and as you saw in the previous chapter, it must stay a Server Component. Dropping 'use client' at the top of the root layout to host the provider would turn your entire app into a client subtree, which is exactly what you don’t want.
This is the <Providers> pattern the previous chapter set up: a thin Client Component at app/_components/providers.tsx that carries the 'use client' directive and wraps the app, and the server layout renders it as a child. Back then the wrapper was essentially empty, holding a bare <ThemeProvider>; now you fill in its configuration. The 'use client' boundary stops at <Providers>, so <html> and <body> stay on the server. Here are both files.
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) { return ( <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider> );}The single client-side provider wrapper from the previous chapter, now configured. Back then <ThemeProvider> had no props. This is also the one spot where every app-wide provider gathers: later chapters add the data-fetching client and the i18n provider right here, beside <ThemeProvider>.
import { Providers } from './_components/providers';import './globals.css';
export default function RootLayout({ children,}: { children: React.ReactNode;}) { return ( <html lang="en" suppressHydrationWarning> <body> <Providers>{children}</Providers> </body> </html> );}Unchanged from the previous chapter except for one attribute. No 'use client' here: the root layout stays a Server Component, rendering <Providers> as a child exactly as before. The only new thing is suppressHydrationWarning on <html>, and it’s the one piece you should never copy without understanding why. That’s the next section.
Each of the four props on <ThemeProvider> earns its place. Here’s what each one does.
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>attribute="class" tells next-themes to express the theme as class="dark" on <html>. That’s the exact hook the last lesson’s @custom-variant dark (&:is(.dark *)) reads, so this prop and that variant are two ends of the same wire. (For a multi-theme app you’d switch this to attribute="data-theme", which the last section covers.)
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>defaultTheme="system" decides what a brand-new visitor gets before they’ve ever picked a theme: the OS preference, not a hardcoded light. A first-time visitor on a dark OS lands in dark.
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>enableSystem makes "system" a real, selectable theme value and activates the OS listener. It’s what makes defaultTheme="system" valid, and what keeps the page tracking the OS when the user never overrides it.
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>disableTransitionOnChange briefly switches off CSS transitions during the swap. Without it, any transition-colors utilities on the page animate every color at once when you toggle, and the whole UI does an ugly half-second fade. It defaults to off, and turning it on is the better choice.
The one attribute that isn’t on the provider, suppressHydrationWarning sitting on <html> in the layout, is the one that confuses people, so it gets its own section.
<html lang="en" suppressHydrationWarning>Why suppressHydrationWarning belongs on <html>, and only there
Section titled “Why suppressHydrationWarning belongs on <html>, and only there”The fix from the first section creates a brand-new problem, and this prop is the answer to it. The chain runs as follows.
The inline script mutates <html>’s class before React hydrates, which is the entire point of it. So when React does hydrate and compares the DOM it finds against the HTML the server sent, it sees a discrepancy: the server sent <html> with no class, but the live DOM has <html class="dark">. React calls that a hydration mismatch and logs a warning.
That mismatch is not a bug; it’s the fix working as designed. The class genuinely is different, because you deliberately changed it before hydration to win the race against first paint. suppressHydrationWarning is how you tell React, “I know this one element’s attributes were changed on purpose before hydration; don’t warn about it.” You’re not hiding a problem, you’re acknowledging a change you made deliberately.
Three precise things to hold onto, because each one is easy to get backwards:
- It goes on
<html>specifically, because<html>is the element the script mutates. The class lands there, so the acknowledgement goes there. - It is shallow. It silences mismatch warnings for that one element’s own attributes only, not for any of its descendants. That means it can’t accidentally mask a real mismatch deeper in your tree, which is exactly why it’s safe here and why you don’t scatter it around.
- It is not a general “make hydration errors go away” switch. Putting it on some other element to quiet a real mismatch just hides a real bug. It belongs here, and only here, because the mutation is intentional and comes from outside React.
Check your model of it:
Putting suppressHydrationWarning on <html> is the right call here. Which statements explain why it’s correct in this specific spot? Select all that apply.
<html>’s class before React gets to run, so React is bound to find a class that was never in the server’s HTML — the difference is on purpose, not a defect.<html>’s own attributes, so a real mismatch further down the tree would still surface.<html> altogether, so its class can never end up in conflict.bg-background or text-foreground needs it too, or those utilities won’t line up between server and client.<html> before hydration — and it does so without reaching past <html> into its descendants, which is exactly what keeps it safe. It doesn’t switch hydration off (React still hydrates <html> normally; it just stays quiet about that attribute), and it has nothing to do with theme-token utilities, which match fine on both sides because CSS resolves identically server and client.Building the theme toggle
Section titled “Building the theme toggle”Now for the part the user actually clicks. The same mismatch problem comes back one level down. A toggle naturally wants to show the icon for the current theme: a sun in light mode, a moon in dark. But the server doesn’t know the theme (the same root cause as the flash), so if the button renders the current-theme icon during the server render, React hydrates and finds a different icon than the server sent. That’s a mismatch on the button.
There are two clean ways out. We’ll lead with the one that needs no React state at all, since it’s the right choice for a plain light/dark toggle.
'use client';
import { Moon, Sun } from 'lucide-react';import { useTheme } from 'next-themes';
export const ThemeToggle = () => { const { resolvedTheme, setTheme } = useTheme(); return ( <button type="button" aria-label="Toggle theme" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')} > <Sun className="inline dark:hidden" /> <Moon className="hidden dark:inline" /> </button> );};This is the default for a plain light/dark toggle. Both icons always render, so the markup is byte-for-byte identical on server and client and there’s nothing to mismatch. Only CSS decides which one is visible, keyed off the .dark class the inline script already set before paint. useTheme() is used purely to write on click, through setTheme, which runs after hydration and is always safe.
'use client';
import { useEffect, useState } from 'react';import { useTheme } from 'next-themes';
export const ThemeToggle = () => { const { resolvedTheme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []);
if (!mounted) { return <button type="button" aria-label="Toggle theme" className="size-9" />; }
return ( <button type="button" aria-label="Toggle theme" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')} > {resolvedTheme === 'dark' ? 'Dark' : 'Light'} </button> );};Reach for this only when the button’s content depends on the theme, for example a menu that shows the active theme’s label as text. Because you have to read the theme in React to render text from it, you wait until after mount: a mounted flag flips to true in an effect, and until then you render a same-size placeholder so the layout doesn’t jump. useState and useEffect are taught properly in a later chapter; here you’re just recognizing the shape, not mastering the hooks.
So for a simple light/dark switch, the CSS-only swap is the default: it sidesteps the mismatch by construction, with no hooks and no placeholder. Reach for the mount-gate only when the control’s content, not just its styling, has to branch on the theme.
You may have noticed that both toggles read resolvedTheme, not theme, to decide the next value. That choice is the one useTheme() subtlety that trips most people up, so it’s worth a closer look.
theme vs resolvedTheme
Section titled “theme vs resolvedTheme”useTheme() hands back an object: { theme, setTheme, resolvedTheme, systemTheme, themes }. Two of those values look interchangeable but aren’t, and mixing them up is the classic next-themes bug.
The distinction is the setting versus the result:
themeis the setting the user picked, and it can be the literal string"system".resolvedThemeis the concrete theme in effect right now, always"light"or"dark", aftersystemhas been resolved against the actual OS preference.
When the user has chosen “system” and their OS is dark, here is what each one holds:
const { theme, resolvedTheme } = useTheme();
theme; // 'system' — the setting the user choseresolvedTheme; // 'dark' — what 'system' actually resolves to right nowThis makes the trap clear. A toggle that computes the next theme from theme breaks the moment theme is "system", because there’s no sensible “opposite of system” to flip to. Always read resolvedTheme when you need to know what’s actually on screen, which is why both toggles above flip on resolvedTheme === 'dark'. (The hook returns more than this, namely systemTheme and themes, but you won’t need them for a light/dark toggle.)
Now build the swap yourself. The exercise below renders your toggle on a light surface and again under a .dark ancestor, so you can watch the icon switch as the variant fires. The <style type="text/tailwindcss"> line at the top is just the harness teaching this standalone preview what dark: means. In a real app, the @custom-variant line from last lesson’s globals.css does that, and next-themes toggles the class for real.
The same toggle is rendered twice: on a light page (top) and under a .dark ancestor (bottom). Right now both glyphs show in both rows. Add visibility classes to the two spans so the sun (☀) shows only in the light row and the moon (☾) shows only in the dark row, matching the target. The glyphs are plain text so the exercise stays about the dark: variant, not about importing an icon set.
Verifying it works
Section titled “Verifying it works”Two checks turn this from “I think it works” into “I watched it work.” They also double as a triage routine: when a teammate says “dark mode is broken,” these two checks tell you which half is broken. It’s worth building the habit now.
-
Open DevTools and inspect the
<html>element, then toggle the theme. Theclassattribute should flip between absent and"dark"on every click. What each outcome tells you:- No class ever appears. The problem is on this side: the provider isn’t wrapping the tree, or
attributeis misconfigured. Start atapp/_components/providers.tsx. - The class flips, but the colors don’t change. The wiring here is fine; the problem is on the Tailwind side from last lesson, either the
@custom-variant darkline or the token overrides inglobals.css. A class that’s present but changes no colors points at last lesson; no class at all points at this one.
- No class ever appears. The problem is on this side: the provider isn’t wrapping the tree, or
-
Hard-reload the page with dark active, and watch the first frame. No flash means the inline script is doing its job and the class is set before paint. A flash means the script isn’t running: most likely the provider is missing or misplaced, or you added a competing effect-based theme setter that’s fighting it. The fix is to remove the stray setter and let
next-themesown the class.
To lock in the timing model that all of this rests on, put the load sequence back in order. Drag these into the order they actually happen.
Order the events from a fresh page load with dark mode active, from server to first user interaction. Drag the items into the correct order, then press Check.
<head> script runs and sets .dark on <html> setTheme flips the theme Beyond light and dark
Section titled “Beyond light and dark”One extension is worth naming so you recognize it when a project needs it. Some apps ship more than two themes: a marketing site with brand variants, or an app with a high-contrast mode. The model stretches cleanly to cover them. Switch <ThemeProvider> to attribute="data-theme", pass the list with themes={['light', 'dark', 'blue', 'high-contrast']}, and add a [data-theme="blue"] { … } token block in globals.css for each one. It’s the same token model as last lesson, just keyed off an attribute instead of the .dark class. This is rare in SaaS dashboards and common on marketing and agency sites. You won’t build it here; just know the same machinery scales to it.
External resources
Section titled “External resources”Josh Comeau derives the pre-paint inline-script fix from first principles, the deepest take on the flash this lesson is built to fix.
The authoritative word on the one prop that confuses people: one level deep, an escape hatch, not for general use.
The canonical API: every ThemeProvider prop and the full useTheme() surface, straight from the source.
shadcn's setup. Note its standalone theme-provider.tsx, the per-provider shape our single Providers consolidates.