Design a theme switcher with dark mode
A Light, Dark and System control that shows the right theme from the very first paint on every page. Flipping colors is easy; the real work is the three-state choice and where it lives, the tiny script that prevents a white flash, semantic color tokens, images and charts that follow along, contrast in both themes, tabs that stay in sync, and stepping aside for the operating system's forced colors.
The question
Users of our project-management app keep asking for dark mode. Design the theme switcher: how people pick a theme, how every page shows it from the first frame, and how the choice stays the same in every tab and on every device.
We have a public marketing site and docs, served as cached HTML from a CDN, and a signed-in dashboard with about 20 charts. Both are server-rendered with Next.js, and the site has a strict Content Security Policy.
Real-life examples: GitHub's appearance settings, Slack's theme preferences, The MDN Web Docs theme menu, The Tailwind CSS docs site, The macOS and Windows appearance settings that apps follow
Requirements: what a theme switcher really has to do
A theme is the set of colors a page uses: backgrounds, text, borders, links, chart lines. Dark mode is a theme with dark backgrounds and light text. A theme switcher is the control that lets a person pick one, plus all the machinery that makes every page show the picked theme, every time, from the first frame.
It sounds like a one-liner: add a class to body, swap a few colors. That's why interviewers like it. The naive version flashes white on every page load at night, forgets the choice on a new laptop, ignores the operating system, leaves dark logos on dark backgrounds, draws charts in the old colors, and fails contrast checks in one of the two themes. A strong answer is mostly about those details.
Questions to ask
The middle column is what this guide assumes. The right column is why the answer matters.
| Question | Answer we assume | What it changes |
|---|---|---|
| Only light and dark, or more themes later? | Light and dark now; a high-contrast theme is on the roadmap. | Rules out a boolean isDark spread through components. We need a named theme in one place and tokens per theme. |
| Should we follow the operating system? | Yes, by default. Users can override it for our app and later go back to following the OS. | Three states, not two: Light, Dark and System. The stored value is the choice, not the color. |
| Where is the choice remembered? | In the browser for everyone, including logged-out docs readers. On the profile for signed-in users. | Two stores, a browser one and an account one, and a rule for which wins. |
| How are pages rendered and cached? | Marketing and docs: the same HTML for everyone, cached on a CDN for 10 minutes. Dashboard: rendered per user, never cached. | The CDN pages can't contain the user's theme in their HTML; the dashboard can. Two different no-flash strategies. |
| Is there a Content Security Policy? | Yes, strict: no 'unsafe-inline' for scripts. | The inline script that prevents the flash needs a hash in the policy. |
| What images and charts do we show? | SVG icons, a logo, product screenshots, user avatars, about 20 canvas charts, and a few third-party embeds. | Each kind needs its own plan; CSS alone can't recolor a canvas or a PNG. |
Functional requirements
- Pick Light, Dark or System from a control in the header and from the settings page.
- Remember the choice across visits, and across devices for signed-in users.
- With System picked, follow the OS setting, including when it changes while the page is open (many people have the OS switch to dark at sunset).
- Show logos, screenshots, icons and charts correctly in both themes.
- A change in one tab shows up in every other open tab of the site within a moment.
Non-functional requirements, with numbers
- No flash of the wrong theme, on any page, on the first visit or the hundredth, with a slow or fast network. Zero frames in the wrong colors.
- Contrast in both themes: body text at least 4.5:1 against its background, large text at least 3:1, icons, input borders and chart lines at least 3:1 (WCAG 2.2, success criteria 1.4.3 and 1.4.11).
- Fast switch: the new theme shows within one frame (about 16 ms on a 60 Hz screen) of the click, with no layout shift and no re-render of the React tree.
- Tiny cost: the code that runs before the first paint stays under about 1 KB and under 1 ms of main-thread time on a mid-range phone.
- Works with the constraints: CDN-cached HTML, a strict CSP, server rendering and hydration.
- Accessible control: reachable by keyboard, announces its current state to screen readers, and the app steps aside when the user turns on Windows contrast themes (forced colors).
Out of scope, and why
- User-made themes with their own accent colors: that needs a color picker and a contrast check for any color. Nobody asked for it yet, but our token design shouldn't block it.
- Emails and exported PDFs: they render in mail clients and PDF viewers we don't control.
- Scheduling (dark only after 8 pm): the OS already does this, and System mode follows it for free.
Check your understanding · Exercise 1
Which answers from the interviewer change the design the most? Select all that apply.
What makes theming hard
Five traits of this problem drive every later decision. Name them out loud early; each one becomes a chapter.
- The answer depends on two inputs, not one. The user's choice (Light, Dark, System) and the operating system's setting combine into the theme you actually paint. Store the wrong one and "System" silently breaks.
- It must be right before the first paint. The browser paints as soon as it has HTML and CSS, often 100 to 300 ms before your JavaScript bundle runs. If the theme is decided in a React effect, every dark-mode user sees a white frame first. At night, on an OLED phone, that flash is the bug users report.
- It touches every pixel. Hundreds of components, written by many teams over years, each with colors. If any of them hard-codes
#fff, it stays white in dark mode. The fix is architecture (tokens), not a search and replace. - Not everything is CSS. Canvas charts read colors once in JavaScript. PNG logos have colors baked in. Third-party iframes have their own pages. Native form controls and scrollbars are drawn by the browser. Each needs its own route to the theme.
- It lives in many places at once. Several open tabs, a phone and a laptop, a server that renders HTML, a CDN that caches it. A change in one place must reach the others, and they must agree on the first frame.
Notice what's missing: scale. There is no feed of a million items and no server load to plan. The hard part is timing (what runs before the first paint) and coverage (every pixel, every surface). Interviewers use this problem to see whether you understand the browser's rendering pipeline and CSS architecture, not distributed systems.
Think first
A teammate proposes storing isDark: true in localStorage when the user clicks the toggle, and reading prefers-color-scheme only when nothing is stored. What goes wrong?
Once a user clicks anything, the OS setting is ignored forever. There is no way to say "go back to following my system", because the stored value can only be true or false.
A user who picked System on a laptop that switches to dark at sunset gets stuck in whatever theme was active when they last clicked. Store the choice ("light", "dark" or "system"), and compute the painted theme from the choice plus the OS every time.
Three states, not two: the choice, the resolved theme, and where each lives
Separate two values from the start. The choice is what the user picked: "light", "dark" or "system". The resolved theme is what you paint: always "light" or "dark". You store the choice and compute the resolved theme.
With System, the resolved theme comes from the CSS media query prefers-color-scheme, which the browser fills in from the OS setting (or from its own appearance setting, if the user set one). In JavaScript you read it with matchMedia("(prefers-color-scheme: dark)").matches.
change event on the media query flip the page.The figure shows why System is a real state. From Light or Dark, an OS change does nothing; from System, it flips the painted theme while the choice stays the same. And from any state, the user can go back to System. With a boolean, that last arrow is impossible.
Where does the choice live?
The choice must be readable at three moments: by a script before the first paint, by the server when it renders HTML, and on a new device after sign-in. No single store is good at all three.
Decision Where do we store the user's choice?
What we pick: Layer them. localStorage is the source the head script trusts on every page. The cookie lets the per-user dashboard render the right HTML on the server. The account setting carries the choice to new devices. The rule: at sign-in the account value wins and is copied into the local stores; after that, every explicit change writes all three.
When the copies disagree
Say a user picked Dark on their laptop, then signs in on a work desktop where they had picked Light as a visitor last week. Which wins? Keep it simple and predictable: an explicit choice stored with the account is the user's real preference, so at sign-in the account value overwrites the local copy, and the page switches if needed.
If the account has no value yet, upload the local one instead. After that, every change writes all three: localStorage and the cookie at once, the account with a PUT in the background.
type ThemeChoice = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";
const KEY = "theme";
function readChoice(): ThemeChoice {
try {
const v = localStorage.getItem(KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {
// Storage blocked by the user's settings: fall through.
}
return "system";
}
function resolve(choice: ThemeChoice): ResolvedTheme {
if (choice !== "system") return choice;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
For this problem the deciding fact about storage is simple: of the browser stores, only localStorage and cookies can be read synchronously before the first paint (IndexedDB is asynchronous), and only cookies reach the server.
Check your understanding · Exercise 2
A user picks System. Their OS is light. At 7 pm the OS switches to dark while the app is open. What should happen? Select all that apply.
The first paint: no flash of the wrong theme
Start with how a page loads. The browser downloads the HTML, reads it top to bottom, fetches the CSS in the head, and paints as soon as it has enough. Your JavaScript bundle usually arrives later, and React hydrates later still: it attaches its event handlers to the server HTML and takes over. On a mid-range phone on 4G, first paint might be at 400 ms and hydration at 1,800 ms, so anything decided at hydration is more than a second too late.
The naive version and its flash
The naive version puts the theme in React state: useState("light"), then a useEffect reads localStorage and sets "dark". The server renders light, the browser paints light, and a second later the effect runs and the page turns dark. That white frame, then dark, is the flash of incorrect theme. On a dark OLED screen at night, it's a bright blink on every navigation.
Decision How do we make the first paint use the right theme?
What we pick: Use the inline head script everywhere as the guarantee, and add server rendering from the cookie on the per-user dashboard when the cookie says Light or Dark. On the cached marketing and docs pages, the script alone decides. With System, the script decides on every page, because only the browser knows the OS setting.
The script, line by line
// app/layout.tsx: the script text is a constant, so its CSP hash is stable.
const THEME_SCRIPT = `(function () {
var c = "system";
try { c = localStorage.getItem("theme") || "system"; } catch (e) {}
var dark = c === "dark" ||
(c !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
var t = dark ? "dark" : "light";
var root = document.documentElement;
root.dataset.theme = t;
root.style.colorScheme = t;
})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
<body>{children}</body>
</html>
);
}
- Inline, not
src: no download, so no round trip. The bytes are already in the HTML. - In the
head, before content: it runs before the body is parsed, so nothing has painted yet. Thehtmlelement already exists, so it can write the attribute. - Plain ES5, wrapped in a function: no globals leak, no build step, works in any browser that can run the site.
- try and catch around storage: localStorage throws a
SecurityErrorwhen the user blocks site data. Without the catch, the whole script dies and the page falls back to light. - Unknown values fall to System: anything other than
"light"or"dark"follows the OS. - Sets
color-schemetoo: so native controls and scrollbars match from the first frame (next chapter).
Cost: about 350 bytes before compression, one storage read and one media query check. On a slow phone it runs in a fraction of a millisecond. That's why it's acceptable to block the parser for it, and also why it must stay that small: no imports, no JSON parsing of big objects, no network.
Getting it past a strict CSP
A strict Content Security Policy blocks inline scripts unless the policy names them. Two ways. A nonce is a random token the server puts in the header and on the script tag; it must be fresh per response, so it fits the per-user dashboard but not CDN-cached pages, where every visitor gets the same HTML.
A hash is the SHA-256 of the script's exact text, listed as 'sha256-...' in script-src. The script never changes, so the hash is stable and works on cached pages. Generate it at build time so an edit can't silently break it.
Hydration mismatches
The server sent html without data-theme, and the script added one (plus a style attribute for color-scheme) before React hydrated. React compares attributes and would warn about the difference. Adding suppressHydrationWarning on the html element tells React that this element's attributes are expected to differ. It only applies one level deep, to the html element's own attributes and not to its children, which is exactly the scope we want.
The bigger trap is React rendering that depends on the theme. A toggle that shows a sun or a moon icon can't know the theme on the server. If the server renders the sun and the client renders the moon, React reports a hydration error.
Two fixes: render both icons and let CSS show one ([data-theme=dark] .sun { display: none }), which keeps the HTML identical, or render a neutral placeholder until after mount. Prefer the CSS route: it shows the right icon on the first paint. The tabs and accordion guides use the same idea: let a tiny script or CSS fix the first frame before hydration, and keep the server HTML identical to what React renders.
Check your understanding · Exercise 3
Our docs pages are cached HTML on a CDN. Which approaches avoid the flash for a user who picked Dark? Pick one.
CSS architecture: semantic tokens, color-scheme and light-dark()
The first paint now has the right attribute on html. The next question is how hundreds of components turn that one attribute into the right colors, without each team writing dark-mode code.
Decision How do components get their colors in each theme?
What we pick: Semantic tokens per theme. Components ask for meaning ("the background of a raised surface"), and the theme answers with a color. This is the core of the design-systems lesson; here we only need the color layer of it.
:root,
[data-theme="light"] {
color-scheme: light;
--color-bg: #ffffff;
--color-surface: #f6f7f9;
--color-text: #1b1f24;
--color-text-muted: #59636e;
--color-border: #808994;
--color-accent: #0a64d6;
}
[data-theme="dark"] {
color-scheme: dark;
--color-bg: #121417;
--color-surface: #1d2127;
--color-text: #e6e8eb;
--color-text-muted: #9ba3ad;
--color-border: #6b7480;
--color-accent: #6cb0ff;
}
.card {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
Notice the two layers: the theme blocks are the only place hex values appear, and the .card rule has no idea a dark theme exists. To add a high-contrast theme later, you write one more block, and every component follows. A stylelint rule such as color-no-hex (allowed only inside the token file) keeps raw colors out of component CSS.
color-scheme: the part people forget
Your tokens recolor your elements. They don't recolor what the browser draws itself: checkboxes, radio buttons, date pickers, select menus, scrollbars, the text cursor, autofill backgrounds, and the default page background (the canvas) that shows during overscroll or before CSS loads. The color-scheme property tells the browser which scheme those defaults should use. Set color-scheme: dark on the root and native controls and scrollbars turn dark too.
The meta name="color-scheme" content="light dark" tag in the head goes one step earlier: it tells the browser, before any CSS arrives, that the page supports both and the canvas may follow the OS. Without it, a dark-OS user sees a white canvas for the instant between the first byte and the stylesheet. Our head script then sets style.colorScheme to the resolved theme, so a user who picked Light on a dark OS gets light native controls too.
The light-dark() function
CSS now has light-dark(lightValue, darkValue). It returns the first value when the element's used color scheme is light and the second when it's dark. So --color-bg: light-dark(#ffffff, #121417) defines both themes in one line, and the theme switch becomes just a change of color-scheme. It's supported in all major browsers since May 2024 (Chrome 123, Firefox 120, Safari 17.5).
It works only for colors, and only for two schemes. A high-contrast or brand theme still needs its own block. So use it as a shorthand inside the light and dark pair if you like, but keep the data-theme attribute as the switch: it scales to more than two themes and gives CSS a hook for things that aren't colors, like a shadow that becomes a border.
Check your understanding · Exercise 4
After shipping dark mode, a QA report says: the checkboxes and the scrollbar are still bright white in dark mode, while everything else is dark. What's the likely cause and fix? Pick one.
Switching at runtime: the click, the OS, and the other tabs
Three things can change the theme while a page is open: the user clicks the control, the OS flips (only matters when the choice is System), or another tab changes the choice. All three end in the same function: compute the resolved theme and write it to html.
PUT fails, the screen the user sees is already right, so we retry quietly instead of reverting.Applying a theme without everything animating
Writing data-theme is instant, but many components have transition: background-color 200ms for hover states. On a theme change, every one of them animates at once, at slightly different speeds, and the page smears from light to dark over a fifth of a second. It looks broken, and on a page with thousands of elements it can drop frames. The fix: turn transitions off for the duration of the switch.
function applyTheme(t: ResolvedTheme) {
const root = document.documentElement;
const style = document.createElement("style");
style.textContent = "*,*::before,*::after{transition:none!important}";
document.head.appendChild(style);
root.dataset.theme = t;
root.style.colorScheme = t;
// Force a style recalculation so the new colors apply with transitions off.
void getComputedStyle(root).color;
requestAnimationFrame(() => style.remove());
}
Reading a computed style forces the browser to recalculate styles right now, with transitions disabled. Then the style tag is removed on the next frame, and hover transitions work again. next-themes offers the same trick behind its disableTransitionOnChange option.
Optional: a circular reveal with View Transitions
Some sites animate the switch on purpose: the new theme grows as a circle from the toggle button. The View Transitions API makes this cheap. document.startViewTransition(() => applyTheme(t)) snapshots the old page, applies the change, and lets you animate the new snapshot, for example with a clip-path: circle() that grows from the click point on ::view-transition-new(root). Same-document view transitions work in Chrome and Edge 111+, Safari 18+, and Firefox 144+ (October 2025).
// CSS: turn off the default cross-fade so only the circle animates.
// ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
async function switchWithReveal(t: ResolvedTheme, x: number, y: number) {
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!document.startViewTransition || reduce) return applyTheme(t);
const transition = document.startViewTransition(() => applyTheme(t));
await transition.ready; // the snapshots and pseudo-elements now exist
const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] },
{ duration: 400, easing: "ease-out", pseudoElement: "::view-transition-new(root)" },
);
}
- Feature-detect: if
document.startViewTransitionis missing, just apply the theme. - Skip the animation when
prefers-reduced-motion: reducematches. A full-screen wipe is exactly the kind of motion that setting is for. - While it runs, the user sees snapshots rather than the live page, so keep it short (300 to 500 ms).
- It's polish, not a requirement. Say it in the interview as an option, after the basics are solid.
Listening to the OS, only when it matters
useEffect(() => {
if (choice !== "system") return;
const mq = matchMedia("(prefers-color-scheme: dark)");
const onChange = () => {
const t = mq.matches ? "dark" : "light";
applyTheme(t); // write the attribute first...
setResolvedTheme(t); // ...then tell charts and logos
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [choice]);
The listener exists only while the choice is System. When the user picks Dark, the effect cleans it up, so a sunset flip on the OS can't override an explicit choice. When they go back to System, setChoice applies the current OS value right away, and the effect subscribes again for later changes.
Keeping tabs in sync
Decision How do other open tabs learn about a change?
What we pick: The storage event. We already write the choice to localStorage, so the event comes free and carries the new value. BroadcastChannel is a fine choice when there is no storage write to piggyback on; see the cross-tab-sync lesson for the general case. The receiving tab must validate newValue, update its React state, and apply the theme (with transitions off).
Check your understanding · Exercise 5
A user has two tabs open. In tab A they switch from System to Dark. Which statements are true? Select all that apply.
Images, shadows, charts and embeds: what CSS tokens can't reach
Tokens handle everything drawn with CSS colors. A real app also paints pixels that CSS can't recolor. Go through them one by one; interviewers like candidates who list them without being asked.
| What | Problem in dark mode | Plan |
|---|---|---|
| Icons (inline SVG) | Black strokes vanish on a dark background. | Draw with fill="currentColor" or stroke="currentColor", so the icon takes the text color token. |
| Logo | A dark wordmark disappears. | Two files, one per theme, switched by the theme attribute (below). |
| Screenshots and diagrams | A big white rectangle in a dark page glares. | A dark version where it matters (hero images); otherwise a border and slightly lower brightness. |
| Photos and avatars | Look fine, but bright ones glare a little. | Leave the colors alone. Optionally filter: brightness(0.9) in dark mode. Never invert. |
| Shadows | A black shadow on a near-black page is invisible. | Show elevation with lighter surfaces instead (below). |
| Canvas charts | Colors were read once at draw time; the chart keeps the old theme. | Read tokens in JavaScript and redraw on theme change. |
| Third-party iframes | The embed shows its own theme. | Pass a theme parameter if the vendor supports one; otherwise frame it so a light box looks intentional. |
The picture element trap
The textbook answer for a theme-aware image is a picture element with a source that has media="(prefers-color-scheme: dark)". It works, with one catch: the media query reads the OS setting, not the user's choice in our app. A user who picked Dark on a light OS gets the light logo. So picture is right only for pages without a theme switcher, like a README.
With a switcher, key the image off our attribute instead. For small images like a logo, put both in the HTML and hide one with CSS: [data-theme=dark] .logo-light { display: none }. The browser may download both (a logo is a few KB, so that's fine), and the right one shows on the first paint with no JavaScript.
For a large hero image, render it from React using the resolved theme, accepting that the image request waits for hydration, or keep one image that works on both backgrounds. The image-optimization lesson covers the formats and sizes side.
Shadows and elevation
In light mode, a menu floats above the page because of its shadow. On a dark page, a dark shadow has almost nothing to darken. Material Design's dark theme guidance flips the idea: a dark gray base (#121212) rather than pure black, and the higher a surface sits, the lighter it gets (Material 2 does this with a semi-transparent white overlay that grows with elevation).
So tokens come in elevation steps: --color-surface-0 for the page, --color-surface-1 for cards, --color-surface-2 for menus and dialogs. In light mode those can all be white with growing shadows; in dark mode they step up in lightness, and a thin --color-border helps edges read.
Charts read tokens in JavaScript
A canvas chart library takes colors as JavaScript strings when it draws. It doesn't see CSS variables. Read the tokens with getComputedStyle, and draw again when the theme changes. The same approach is used in the analytics-dashboard guide: tokens are the single source, and the charts read them.
function readChartColors() {
const s = getComputedStyle(document.documentElement);
const v = (name: string) => s.getPropertyValue(name).trim();
return {
text: v("--color-text-muted"),
grid: v("--color-border"),
series: [v("--color-chart-1"), v("--color-chart-2"), v("--color-chart-3")],
};
}
function RevenueChart({ data }: { data: Point[] }) {
const { resolvedTheme } = useTheme();
const [colors, setColors] = useState<ReturnType<typeof readChartColors> | null>(null);
// Read in an effect: the server has no document. The provider writes data-theme
// before it updates resolvedTheme, so this reads the new token values.
useEffect(() => setColors(readChartColors()), [resolvedTheme]);
if (!colors) return <ChartSkeleton />;
return <LineChart data={data} colors={colors} />;
}
With 20 charts on the dashboard, a theme change redraws 20 canvases. Each redraw takes a few milliseconds, so the whole switch might take 50 to 100 ms of main-thread work. That's fine for a click the user expects to change everything. Charts in hidden tabs or offscreen can redraw lazily when they become visible.
Embeds that don't follow
An iframe is another document with its own CSS. It can't read our tokens. Some vendors accept a theme parameter: embedded X (Twitter) posts take data-theme="dark", for example. Pass the resolved theme when you create the embed, and recreate it on change if the vendor has no live API.
For embeds you can't theme at all, put them in a card with a visible border, so a light box looks deliberate rather than broken. For our own iframes, post the theme with postMessage and have the inner page run the same token system.
Check your understanding · Exercise 6
A user on a light OS picks Dark in our app. Which images show the wrong version? Select all that apply.
Contrast in both themes, and more than two themes
Dark mode doubles the contrast work. Every pair of tokens that sits on top of another (text on background, muted text on a card, a border on a surface, an accent link on the page) must pass in each theme. A color that passes in light mode often fails in dark: our light accent #0a64d6 has 5.5:1 on white, but only 3.3:1 on our dark background, too low for text.
The rules, in numbers
| What | Minimum (WCAG 2.2 AA) | Example pair to check |
|---|---|---|
| Body text (1.4.3) | 4.5:1 | --color-text and --color-text-muted on every surface |
| Large text: 24 px, or 18.66 px bold (1.4.3) | 3:1 | Headings on --color-bg |
| UI parts and graphics (1.4.11) | 3:1 | Input --color-border, focus ring, icons, chart lines on their surface |
| Disabled controls | No minimum | Still make them readable; don't rely on the exemption |
Because components only use tokens, you can check contrast once per token pair instead of once per screen. Keep a list of the pairs that are allowed to touch (about 30 for a mid-size app) and a unit test that computes each ratio in each theme. A designer who darkens --color-surface-2 gets a failing test before the change ships. The visual-accessibility lesson explains how the ratio is computed.
Why not pure black and pure white
- Too much contrast tires the eyes. White on black is 21:1. Some readers, especially people with astigmatism, see bright text on black bleed and glow, an effect called halation. A dark gray background with off-white text (about 15:1) reads more calmly and still passes easily.
- No room for elevation. If the page is already black, there's no darker shadow and no gray step below it. Starting at a dark gray leaves room for lighter raised surfaces.
- Saturated colors vibrate on dark. A pure
#0000ffblue on our dark background is only 2.2:1 and seems to shimmer at its edges. Dark themes use lighter, less saturated tints of the brand colors, as Material's dark theme guidance recommends (our dark accent is#6cb0ff, at 8.2:1). - OLED smearing. On some OLED phones, pixels that switch from fully off (pure black) to on are slow, so scrolling text smears. A near-black background keeps pixels slightly on.
More than two themes
Because the switch is a named attribute and the tokens live in one block per theme, adding a theme is cheap: [data-theme="high-contrast"] with pure text colors, thicker borders, and no subtle grays. The choice type grows to include it. Two related signals come from the OS:
prefers-contrast: moreis set when the user asks the OS for more contrast (on macOS and iOS, for example). You can map it to the high-contrast theme when the choice is System, or nudge borders and muted text darker inside the existing themes.- Forced colors (
forced-colors: active) is set by Windows contrast themes. The browser then replaces your colors with a small palette the user chose, using system colors likeCanvas,CanvasText,LinkTextandButtonText. Your theme must step aside: don't fight it withforced-color-adjust: noneon whole regions. Check that borders, focus rings and selected states still show, since backgrounds and shadows are flattened. The tooltip and star-rating guides show the same care for borders and SVG icons.
@media (forced-colors: active) {
/* Backgrounds are flattened, so a selected option needs a visible marker. */
.theme-option[aria-checked="true"] {
outline: 2px solid Highlight;
}
/* SVG icons that use currentColor already follow; explicit fills don't. */
.icon {
fill: CanvasText;
}
}
Brand themes (a white-label version per customer) are the same idea at a larger scale: each brand is a block of token values, loaded per tenant. The trap is letting a customer pick any accent color; run the same contrast check on their colors before accepting them.
Check your understanding · Exercise 7
Which of these are good practice for a dark theme? Select all that apply.
Architecture, data model and the component API
Theming has two halves that run at different times. Before the first paint, the head script decides and writes the attribute. After hydration, a ThemeProvider takes over: it holds the choice, listens for the OS and other tabs, and saves changes. Both use the same storage key and the same resolve rule, so they always agree.
html. The head script sets it before paint; the provider owns every later write, to the attribute and to all three stores. Only charts and logos need JavaScript to follow it.| Part | Runs | Responsibility |
|---|---|---|
| Head script | During parsing, once per page load | Read the choice, resolve it, set data-theme and color-scheme on html. |
| Token stylesheet | Always (CSS) | Map semantic variables to values per theme; the only place with raw colors. |
ThemeProvider | After hydration | Hold the choice and resolved theme in React state; apply changes; listen to matchMedia (System only) and storage; write localStorage, the cookie and the account. |
ThemeControl | In the header and settings | A three-option control that calls setChoice. |
| Theme-aware media | On change | Charts, large images and embeds that read resolvedTheme and redraw or swap. |
| Preferences API | Server | Store a signed-in user's choice on their profile; return it with the session. |
Why these boundaries? The head script is separate because it must be tiny and run before React exists. The provider owns all writes, so there's one place where the three stores are kept in step. Components never subscribe to the theme to get colors (CSS does that), so a switch doesn't re-render the tree; only the few media components that truly need JavaScript subscribe.
PUT.Data model
type ThemeChoice = "light" | "dark" | "high-contrast" | "system";
type ResolvedTheme = Exclude<ThemeChoice, "system">;
// localStorage key "theme": the choice, as a plain string.
// Cookie "theme": the same string, read by the server on per-user pages.
// Account, on the server.
type AppearancePreference = {
theme: ThemeChoice;
updatedAt: string; // ISO time of the last explicit change
};
// React context, in memory.
type ThemeContextValue = {
choice: ThemeChoice;
resolvedTheme: ResolvedTheme;
setChoice: (c: ThemeChoice) => void;
};
Note what's not stored: the resolved theme. It's derived from the choice and the OS each time. Storing it would go stale the moment the OS changes.
The server API
GET /api/me/preferences
200 { "theme": "dark", "updatedAt": "2026-03-02T19:04:11Z" }
PUT /api/me/preferences
{ "theme": "system" }
204 No Content
401 { "error": "unauthenticated" } // signed out: keep local only
The PUT is fire and forget from the user's point of view: the theme already changed locally. If it fails, keep the local value, retry once on the next page load, and don't show an error toast. Being out of sync on another device for a day is a tiny cost; nagging is not.
The control: which pattern?
Decision What kind of control lets the user pick?
What we pick: A radio group on the settings page (a segmented control of three native radios, styled), and a menu button in the header where space is tight. Both call the same setChoice. If a design insists on a single icon toggle, make it cycle Light, Dark, System with a visible label of the current state, and be honest that it's slower to use.
export function ThemeRadios() {
const { choice, setChoice } = useTheme();
const options = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
] as const;
return (
<fieldset className="segmented">
<legend>Theme</legend>
{options.map((o) => (
<label key={o.value}>
<input
type="radio"
name="theme"
value={o.value}
checked={choice === o.value}
onChange={() => setChoice(o.value)}
/>
{o.label}
</label>
))}
</fieldset>
);
}
One hydration detail: on the server the provider doesn't know the choice on cached pages, so checked would be wrong in the HTML. Render the control with no option checked until mount (it's below the fold on settings, and the header menu is closed anyway), or on per-user pages pass the cookie value into the provider so the server HTML is already right. The component-api-design lesson covers the wider question of how much the provider should expose; keep it to choice, resolvedTheme and setChoice.
Check your understanding · Exercise 8
Which statements about this architecture are true? Select all that apply.
Testing and performance
Theme bugs are visual and timing bugs, so unit tests alone miss most of them. Test at three levels.
- Unit: the resolve rule (choice plus OS gives a theme, bad stored values fall back to System) and the contrast of every allowed token pair in every theme.
- Visual regression: screenshot key pages and components in light, dark and forced colors, and compare against approved images. A hard-coded
#fffshows up as a white box in the dark screenshot. - End to end: the flash itself. Load a page with the choice stored as Dark and check the
data-themeattribute and background color on the very first response, before scripts from the bundle run. Also check that a change in one page reaches a second page in the same context.
import { test, expect } from "@playwright/test";
test.use({ colorScheme: "dark" }); // emulates prefers-color-scheme
test("System choice follows a dark OS on first paint", async ({ page }) => {
await page.goto("/docs");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page).toHaveScreenshot("docs-dark.png");
});
test("Light choice wins over a dark OS", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("theme", "light"));
await page.goto("/docs");
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});
test("forced colors keep the selected option visible", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/settings");
await expect(page).toHaveScreenshot("settings-forced.png");
});
Playwright's colorScheme option and page.emulateMedia set prefers-color-scheme and forced-colors for the page, so the OS side of the matrix runs in CI without changing any machine settings (forced colors emulation is reliable only in Chromium, so run that test there). Run the visual suite on each theme; it doubles the screenshots, so keep it to the 20 or 30 most important views.
Performance budget
| Cost | Budget | Why it's fine or what to watch |
|---|---|---|
| Head script size | Under 1 KB | About 350 bytes; it ships in every HTML response, uncached on its own. |
| Head script run time | Under 1 ms | One storage read and one media query. It blocks parsing, so no loops and no JSON. |
| Layout shift (CLS) | 0 | Themes change colors only, never sizes. Watch fonts or borders that differ in width per theme. |
| Switch time | One frame for CSS; under 100 ms with charts | Style recalculation for one attribute is fast; chart redraws are the main cost. |
| Re-renders on switch | Only theme-aware media | Colors come from CSS, so the React tree stays still. |
The flash also hurts your Core Web Vitals indirectly: if the theme is applied late and changes sizes (a heavier border in dark mode, say), the elements move after the first paint, which counts as layout shift. Keep both themes the same size, pixel for pixel. The core-web-vitals lesson covers how CLS is measured.
Check your understanding · Exercise 9
Which tests would catch a component that hard-codes background: #fff? Select all that apply.
Running it in the interview
In 35 minutes, the interviewer wants to see that you understand when code runs relative to the first paint, and that you can design colors as a system. Spend the time roughly like this:
| Minutes | What to do | What to say or draw |
|---|---|---|
| 0 to 5 | Requirements | Ask about more themes, following the OS, caching and CSP. State the no-flash and contrast numbers. |
| 5 to 10 | Three states | Draw the choice and the resolved theme. Say "I store the choice, never the color." |
| 10 to 18 | First paint | Draw the load timeline. Explain the inline head script, the CSP hash, and the cookie for per-user pages. |
| 18 to 25 | Tokens | Show the two-layer CSS: semantic variables per theme, color-scheme, then images and charts. |
| 25 to 32 | Runtime and API | Click flow, OS listener only for System, storage event across tabs, the provider and the control. |
| 32 to 35 | Polish | Contrast checks per token pair, forced colors, visual tests in both themes. |
Draw the timeline first: HTML arrives, the head script runs, CSS applies, first paint, bundle, hydration. Everything else hangs off it, and it shows the interviewer you think in terms of the rendering pipeline.
Think first
Follow-up from the interviewer: can't we do it all on the server with the cookie and drop the inline script?
On per-user pages, for Light and Dark, yes: the HTML arrives right, no script needed. But two cases still need the script.
First, CDN-cached pages serve the same HTML to everyone, so they can't carry a per-user attribute without splitting the cache. Second, System depends on the OS setting, which the server can't see reliably (the color-scheme client hint is Chromium-only). So the script stays as the guarantee, and the cookie is an improvement where it applies.
Think first
Follow-up from the interviewer: product now wants a third theme, high contrast. What changes?
One new token block, [data-theme="high-contrast"], one new value in the choice type, one more option in the control, and the contrast tests and screenshots run for it too. No component changes, because components only use semantic tokens.
If the choice is System, I could also map prefers-contrast: more to it. And forced colors still wins over all of our themes.
Summary
- Store the choice (Light, Dark or System), never the painted color. Resolve it from the choice plus
prefers-color-schemeevery time. - A tiny inline script in the head sets
data-themeandcolor-schemeonhtmlbefore the first paint. It must be inline, synchronous, wrapped in try and catch, and allowed by a CSP hash on cached pages. - On per-user server pages, read a cookie and render the right attribute, but keep the script for System and for CDN-cached HTML. Add
suppressHydrationWarningonhtmlonly, and keep theme-dependent markup out of the server render. - Components use semantic tokens like
--color-bg; one block per theme maps them to values. Adding a theme is one block, and contrast is checked once per token pair. color-scheme(and the meta tag) makes native controls, scrollbars and the canvas follow;light-dark()is a handy two-theme shorthand, not a replacement for named themes.- Images, charts and embeds need their own routes:
currentColorSVGs, logos switched by our attribute (not apicturemedia query), lighter surfaces for elevation, and charts that read tokens withgetComputedStyleand redraw. - Switch with transitions disabled, listen to the OS only while the choice is System, sync tabs with the
storageevent, and save to the account in the background. - Respect forced colors, avoid pure black and saturated colors on dark, and test both themes with screenshots and emulated media in CI.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- prefers-color-scheme MDN: the media query, its values and matchMedia use.
- color-scheme MDN: how the property changes native controls, scrollbars and the canvas.
- light-dark() MDN: the two-value color function and its dependence on color-scheme.
- prefers-color-scheme: Hello darkness, my old friend web.dev: a long practical walk through dark mode on the web.
- Improve dark mode default with color-scheme and a meta tag web.dev: what the meta tag and the property change before and after CSS loads.
- View Transition API MDN: startViewTransition, the snapshot pseudo-elements and browser support.
- next-themes A widely used Next.js implementation of the inline script and provider; worth reading its source.
- Understanding SC 1.4.3: Contrast (Minimum) W3C: the 4.5:1 and 3:1 text rules.
- Understanding SC 1.4.11: Non-text Contrast W3C: the 3:1 rule for borders, icons and chart lines.
- forced-colors MDN: Windows contrast themes, system colors and forced-color-adjust.
- Dark theme (Material Design 2) Material's guidance on dark gray surfaces, elevation and desaturated colors.
Concepts behind it
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.