Skip to content
Chapter 28Lesson 11

Flicker-free theme toggle

The header has carried an empty theme-toggle slot since you built it. By the end of this lesson it holds a real icon button: click it and the whole page flips between light and dark, the choice survives a hard reload, and the first paint never flashes the wrong theme or trips a hydration warning. The hero already swaps its marketing image with the active theme; this toggle is the control that lets the visitor choose that theme.

The page in dark theme after the toggle flips it.

Add an icon button to the header that toggles the page between light and dark and remembers the choice across reloads. The interesting part is the shape of the solution. The common way to write a next-themes toggle uses a mounted flag — a useState(false) flipped to true in a useEffect — to gate the render so the server and client never disagree on which icon to draw. You will write a toggle with no such gate. Render both the sun and the moon on every paint and let one CSS class on <html> decide which one shows. When the markup is identical on the server and the client and only the styling branches on the theme, there is nothing for React to mismatch.

The no-flash behavior is already handled by the <ThemeProvider> and the pre-paint script that next-themes injects to set the theme class before React hydrates. Your job is to not regress it: keep the component a Client Component ('use client'), and never read the theme during render. Read it only inside the click handler, where the toggle flips to the opposite of the theme the page is actually showing. Use resolvedTheme, not theme; the reference solution explains the difference. The header already imports this component, so you are filling a waiting slot, not wiring anything new.

Out of scope: a three-way light / dark / system menu, and any per-route theme override.

Clicking the toggle flips the whole page between light and dark.
tested
The chosen theme persists across a hard reload, with the first paint already showing the correct theme and no flash.
untested
No React hydration mismatch warning appears in the console on reload.
untested
The toggle is an icon button with an accessible label and a decorative, hidden-per-theme icon pair.
tested
The toggle is reachable by keyboard from the header and activates on Enter.
untested

Open src/components/theme-toggle.tsx. The header already imports it, so it fills in as soon as it renders something real. Build it against the brief and the tests before you read on.

Reference solution and walkthrough

The whole component is one file and twenty-five lines:

src/components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export const ThemeToggle = () => {
const { resolvedTheme, setTheme } = useTheme();
return (
<Button
type="button"
variant="ghost"
size="icon"
data-testid="theme-toggle"
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</Button>
);
};

Three spots carry the teaching. Read them one at a time.

src/components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export const ThemeToggle = () => {
const { resolvedTheme, setTheme } = useTheme();
return (
<Button
type="button"
variant="ghost"
size="icon"
data-testid="theme-toggle"
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</Button>
);
};

Both icons always render. No ternary picks one over the other: server and client both render both glyphs, so the markup is byte-identical and there is nothing for React to flag as a hydration mismatch. A mounted/useEffect gate exists only to protect a component whose content branches on the theme. Render both and that whole problem disappears.

src/components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export const ThemeToggle = () => {
const { resolvedTheme, setTheme } = useTheme();
return (
<Button
type="button"
variant="ghost"
size="icon"
data-testid="theme-toggle"
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</Button>
);
};

The swap is pure CSS. next-themes toggles a .dark class on <html>. The sun’s dark:hidden shows it at the base and hides it under .dark; the moon mirrors that, hidden at the base and shown only under .dark. The moon’s leading hidden matters: drop it and both icons would flash at once for one frame in light mode.

src/components/theme-toggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export const ThemeToggle = () => {
const { resolvedTheme, setTheme } = useTheme();
return (
<Button
type="button"
variant="ghost"
size="icon"
data-testid="theme-toggle"
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
</Button>
);
};

resolvedTheme is read only inside the click handler. Use resolvedTheme, not theme: theme can be the literal "system", while resolvedTheme is always the concrete "light" or "dark" on the page, so flipping it always lands on a real opposite. Reading it only in onClick, never during render, keeps the render deterministic and removes the other reason a mount gate would be needed.

1 / 1

A few choices worth naming:

The icon-button shape. <Button variant="ghost" size="icon"> is the shadcn icon-button, the same square, transparent-until-hover shape you used for the footer’s social icons. Because it is a real <button>, it is keyboard-focusable and fires on Enter and Space with no key handlers of your own. type="button" stops it submitting if it ever sits inside a form.

The label and decorative icons. An icon-only button has no accessible name, so a screen reader announces just “button”; aria-label="Toggle theme" supplies one. The glyphs need no aria-hidden because lucide-react already marks every icon aria-hidden="true", leaving the label as the single accessible name.

Persistence and the correct first paint were wired earlier when you set up next-themes: a pre-paint script sets the .dark class before React hydrates, and suppressHydrationWarning on <html> silences the one attribute it changes. This component only has to keep its render deterministic so it does not break that.

Run the lesson’s test suite.

Terminal window
pnpm test:lesson 11

A pass looks like this:

✓ tests/lessons/Lesson 11.test.ts (8)
✓ Lesson 11 — Flicker-free theme toggle (8)
✓ clicking flips the page between light and dark (3)
✓ renders a labelled icon button with a per-theme icon pair (5)
Test Files 1 passed (1)
Tests 8 passed (8)

The suite mocks useTheme(), renders the toggle to its first-paint markup, and calls the click handler directly. It checks that the click writes the opposite of the resolved theme, that both glyphs ship and sit on separate icons carrying the dark:hidden / hidden dark:block rules, and that the button is a real type="button" with a label. Run pnpm verify too: Biome, the typecheck, and the production build should all pass clean.

Four requirements live outside what a node-environment test can reach. Confirm each by hand in the browser.

Click the toggle and the whole page switches theme — every surface, not just the icon.
untested
Hard-reload in light and in dark, with the OS preference set each way, and confirm the first paint already matches the saved choice with no flash.
untested
On that reload, the console shows no React hydration mismatch warning.
untested
Tab from the URL bar into the header until you reach the toggle — it shows a visible focus ring — and press Enter to fire it.
untested