Flicker-free theme toggle
The header carries a slot for a theme toggle that has been empty 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 page never flashes the wrong theme or trips a hydration warning in the console.
This closes the theme story the hero opened. The hero ships a marketing image that already swaps with the active theme; what it could not do was let the visitor choose that theme. This toggle is the control that does.
The page in dark theme after the toggle flips it.
Your mission
Section titled “Your mission”The feature is small: an icon button in the header that toggles the page between light and dark and remembers the choice across reloads. The reason this gets its own lesson is the shape of the solution. The well-known way to write a next-themes toggle reaches for a mounted flag — a useState(false) flipped to true inside a useEffect, gating the render so the server and the client never disagree on which icon to draw. You are going to write a toggle that has no such gate, and the absence is the entire point. When a control’s markup is byte-identical on the server and the client and only its styling branches on the theme, there is nothing for React to mismatch, so the guard is dead weight. You will ship both the sun and the moon on every paint and let one CSS class on <html> decide which one displays.
The no-flash guarantee is not yours to build — it already lives in the <ThemeProvider> wired into the app and in the pre-paint <script> that next-themes injects to set the theme class before React hydrates. Your job is to not regress it. That means the component stays a Client Component ('use client'), and it never reads the theme during render in a way that would make the first frame disagree with what the script already painted. The toggle itself is a binary flip: read the theme the page is actually showing and write its opposite. Reach for resolvedTheme, not theme — the difference matters, and the reference solution explains why. The header already imports this component, so you are filling a slot the rest of the page has been waiting on, not wiring anything new into the tree.
Out of scope: a three-way light / dark / system menu, and any per-route theme override. The toggle is a two-state flip and nothing more.
Coding time
Section titled “Coding time”Open src/components/theme-toggle.tsx — the header already imports it, so the moment it renders something real, the slot fills. Build it against the brief and the tests before you read on. The file is tiny; the thinking is not.
Reference solution and walkthrough
The whole component is one file and twenty-five lines:
'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> );};There are exactly three spots where the teaching lives. Read them one at a time.
'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. There is no ternary picking one over the other — the server renders both glyphs, the client renders both glyphs, the markup is byte-identical, so there is nothing for React to flag as a hydration mismatch. A mounted/useEffect flag exists only to defend a component whose content branches on the theme; render both and that whole class of problem evaporates.
'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 carries dark:hidden, so it shows at the base and the .dark ancestor turns its display off. The moon is the mirror image: hidden keeps it out at the base, dark:block brings it back only under .dark. The leading hidden on the moon matters — drop it and for one frame in light mode both icons would show at once.
'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. It is resolvedTheme, not theme: theme can be the literal "system", while resolvedTheme is always the concrete "light"/"dark" the page is showing, so flipping it always computes a real opposite. And it is read only in onClick — a gesture long after hydration — never during render, so the render stays deterministic. That is the other half of why no mount gate is needed.
A few choices worth naming:
Why the icon-button shape. <Button variant="ghost" size="icon"> is the shadcn icon-button: a real <button> sized as a square with a transparent background that lights up on hover, the same shape you used for the social icons in the footer. Because it is a genuine <button>, it is keyboard-focusable and fires on Enter and Space for free — you write no key handlers. type="button" keeps it from accidentally submitting if it ever sits inside a form.
Why the label and the decorative icons. A button whose only visible content is an icon has no accessible name — a screen reader announces it as just “button”. aria-label="Toggle theme" supplies that name. You will notice the code does not put aria-hidden on the glyphs: lucide-react already renders every icon with aria-hidden="true" by default, so the sun and moon are decorative out of the box and the label is the single accessible name. This is the icon-only button pattern from the accessibility lesson on ARIA earlier in this chapter.
The provider and the pre-paint script that make persistence and the correct first paint work were wired earlier — persistence is localStorage, and the script sets the .dark class before React hydrates, with suppressHydrationWarning on <html> to silence the one attribute the script legitimately changes. That machinery was covered when you set up next-themes; this component only has to avoid breaking it, which it does by keeping its render deterministic.
The hook this component calls: setTheme, and why resolvedTheme differs from theme.
The ThemeProvider + suppressHydrationWarning + mode-toggle pattern this lesson builds on.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 11A 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() so it can render the toggle to its first-paint markup and call the click handler directly, then asserts on what comes back: the click writes the opposite of the resolved theme, both glyphs ship in the markup, each is aria-hidden, the label is present, the button is a real type="button" button, and the dark:hidden / hidden dark:block rules land on two separate icons. Run pnpm verify too — Biome, the typecheck, and the production build should all pass clean.
Three requirements live outside what a node-environment test can reach. Confirm each by hand in the browser, ticking them off as you go.
The mobile drawer that will host its own copy of this toggle, and the body-scroll lock that goes with it, are the last thing left to build in the next lesson — and that is where the project’s full accessibility sign-off runs.