cf.completefrontend
My progress
SYSTEM DESIGN · #17

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.

QuestionAnswer we assumeWhat 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.
A wireframe of the app in its dark theme. Across the top, the header with the app name on the left and a menu button labelled Theme: System on the right. The button's open menu lists Light, Dark and System, with System checked; the items are menuitemradio. On the left below the header, the settings page card titled Settings: Appearance holds a theme radio group of three native radios, Light, Dark and System, with System selected. Under it, a dark version of the logo and a chart drawn from the color tokens.Projects app, dark themeProjectsTheme: SystemLightDarkSystem (on)menuitemradio x3Settings: AppearanceTheme (fieldset legend)LightDarkSystemthree native radiosdark logochartheader: menu buttonsettings: radios
Two controls, one state. The header uses a compact menu button that shows the current choice; the settings page shows all three options as a radio group. Both call the same setChoice, and the logo and chart below follow the resolved theme.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

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.

A state machine of the user's theme choice. Three states across the top: Light, which paints light and ignores the OS; System, the default; and Dark, which paints dark and ignores the OS. Arrows labelled pick connect Light and System both ways, and System and Dark both ways, and a curved arrow over the top shows that Light and Dark can also be picked directly from each other. From System, two arrows lead down to the resolved theme: paint light when the OS is light and paint dark when the OS is dark. Between those two, arrows labelled OS changes go both ways. Notes: in Light or Dark the OS is ignored; in System the app listens to matchMedia.pick, either wayLightignores the OSSystemthe defaultDarkignores the OSpickpickpaint lightOS is lightpaint darkOS is darkOS changesLight or Dark:OS is ignoredSystem: listensto matchMedia
The stored value is the choice at the top. Only from System does the OS decide what gets painted, and only then does a 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?

localStorage only

Write localStorage.setItem("theme", "dark") on change.

The head script reads it synchronously before the first paint.

Pros

  • Synchronous read, so it works before the first paint.
  • Shared by all tabs of the origin; other tabs get a storage event for free.
  • Works for logged-out visitors and on CDN-cached pages.

Cons

  • The server never sees it, so server HTML can't be right on its own.
  • Stays on one browser; a new laptop starts from scratch.
  • Reading can throw when the user blocks site data, so it needs a try and catch.

Cookie only

Set theme=dark; Path=/; Max-Age=31536000; SameSite=Lax on change.

The server reads it and writes data-theme into the HTML.

Pros

  • The server can render the right theme, so no script is needed for the first paint.
  • Readable from JavaScript too, as long as it isn't HttpOnly.

Cons

  • Sent with every request, about 15 bytes each time.
  • Useless on CDN-cached pages as they are: the cache serves one HTML to everyone. Vary: Cookie splits the cache into a copy per visitor, since every visitor's full cookie header differs. Adding only the theme cookie to the CDN's cache key keeps three copies, but System still needs the browser to decide.
  • No change event across tabs.

Account setting only

Save to the user's profile with an API call.

Read it from the session on the server.

Pros

  • Follows the user to every device.
  • One source of truth for signed-in users.

Cons

  • Logged-out visitors have nowhere to store it.
  • Needs a network round trip to read on the client, far too late for the first paint.

All three, layered

localStorage is the fast local copy for every visitor.

A cookie mirrors it so per-user server pages render it.

The account holds it for signed-in users and seeds the other two at sign-in.

Pros

  • Each moment has a store that's fast at that moment.
  • Works logged out, on cached pages, on server pages, and across devices.

Cons

  • Three copies to keep in step, and a rule for conflicts.
  • A bit more code: about 40 lines in the provider.

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.

ts
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.

A page load from 0 to 2 seconds for a user who picked Dark, on a mid-range phone. The HTML arrives at about 0.15 seconds, the first paint happens at 0.4 seconds, the bundle arrives at 1.2 seconds and React hydrates at 1.8 seconds. Row one, theme set in useEffect: nothing until 0.4 seconds, then a light page until 1.8 seconds, the flash, then dark. Row two, head script: nothing until 0.4 seconds, then dark from the first frame on.HTML, head scriptfirst paint 0.4 sbundle 1.2 shydrated 1.8 s0 s0.5 s1.0 s1.5 s2.0 suseEffectblanklight: the flashdarkhead scriptblankdark from the first frameexample times: a mid-range phone on 4G
The gap between first paint and hydration is where the flash lives. Anything decided in React lands on the right of it; only code that runs while the HTML is parsed lands on the left.

Decision How do we make the first paint use the right theme?

Decide in a React effect

Render a default theme on the server.

After hydration, read storage and set the real theme.

Pros

  • No special setup; plain React.

Cons

  • Paints the default first, then switches: a flash for everyone whose choice differs from the default.
  • The gap is the whole bundle download and hydration time, often over 1 s.

External or deferred script

Put the theme code in theme.js with defer, or in the main bundle.

Pros

  • Cacheable file, easy CSP (same origin).

Cons

  • defer runs after parsing, often after first paint: still a flash.
  • Without defer, a blocking external script adds a network round trip (50 to 300 ms) before anything paints.

Inline blocking script in the head

A few hundred bytes of plain JavaScript inline in the head, before the stylesheet's rules can paint anything.

It reads localStorage and the media query and sets data-theme on the html element.

Pros

  • Runs during parsing, before the first paint, with no network request.
  • Works on CDN-cached HTML, because the HTML is the same for everyone and the script decides per browser.

Cons

  • Blocks parsing for its run time, so it must be tiny (well under 1 ms).
  • Needs a CSP hash or nonce.
  • The server HTML and the live DOM now differ, which React notices during hydration.

Server-render from a cookie

The server reads the theme cookie and writes data-theme into the HTML it sends.

Pros

  • The HTML is right on arrival: no script, no mismatch with React.
  • Works with JavaScript disabled.

Cons

  • Only for per-user pages: a shared CDN cache would serve one person's theme to everyone.
  • A cookie of system still needs the OS setting, which the server can't see (the Sec-CH-Prefers-Color-Scheme client hint exists only in Chromium browsers).

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.

Choice
OS
Page
Applied by
Moment 2 of 4: first paint
A page load of the CDN-cached docs with the choice dark, the OS light, and the theme applied by no script (useEffect). The page should be dark. At the four moments, HTML parsed, first paint, deferred script, and hydrated, the page shows light, light, light, dark. flash: wrong theme for 1.4 s. Now at first paint.the screenlight pageparsedparsing150 msfirst paintlight400 msnowdeferredlight600 mshydrateddark1800 msshould show: darkserver HTML: defaultflash: wrong theme for 1.4 sone cached copy for all
The server HTML has no theme, and nothing runs until React hydrates. Everyone whose theme isn't the default sees the wrong one first.

The script, line by line

tsx
// 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. The html element 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 SecurityError when 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-scheme too: 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?

Per-component dark overrides

Each component has its colors, plus a [data-theme=dark] .card { ... } block, or Tailwind dark: classes on every element.

Pros

  • Easy to start; no shared setup.

Cons

  • Every component needs two sets of colors, and new ones forget the dark set.
  • A third theme means editing every component again.
  • Colors drift: 14 slightly different grays across teams.

Palette variables

Variables named for the color: --gray-900, --blue-500.

Swap their values in dark mode.

Pros

  • One place for the raw colors.

Cons

  • Names lie after the swap: --gray-900 holds a near-white in dark mode.
  • Components still decide which gray to use, so dark mode still needs per-component thought.

Semantic tokens per theme

Variables named for the role: --color-bg, --color-text, --color-border, --color-accent.

One block per theme maps each role to a palette color.

Components use only roles.

Pros

  • Components never mention a theme; adding a theme is one new block.
  • Contrast can be checked once per role pair, for each theme.
  • Switching is one attribute change; the browser recomputes styles without any re-render.

Cons

  • Needs discipline and a lint rule to stop raw colors creeping back in.
  • Naming takes thought up front (about 30 to 60 roles for a mid-size app).

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.

css
: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.

Theme
The Dark theme. Background #121417, surface #1d2127, text #e6e8eb, muted #9ba3ad, border #6b7480, accent #6cb0ff. Contrast: text on bg 15.0 to 1, needs 4.5; muted on surface 6.3 to 1, needs 4.5; link on bg 8.2 to 1, needs 4.5; border on surface 3.4 to 1, needs 3. Every pair passes. Native checkbox and scrollbar: dark.Dark pagebg #121417text #e6e8eblink #6cb0ffcheckbox: darkscrollbar: darktext on bg#e6e8eb on #12141715.0:1min 4.5muted on surface#9ba3ad on #1d21276.3:1min 4.5link on bg#6cb0ff on #1214178.2:1min 4.5border on surface#6b7480 on #1d21273.4:1min 3all 4 pairs pass WCAG AA in Dark
Every pair passes, and native controls follow because color-scheme matches the theme. Components never changed: only the token values did.

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.

A sequence diagram of picking Dark. Columns: user, provider, html, storage, API. The user clicks Dark on the control. The provider sets data-theme to dark on html, which repaints within one frame. The provider writes the choice to localStorage and the cookie. The provider sends a PUT to the preferences API, which replies 204. Failure path: when offline, the PUT fails; the provider keeps the local theme and retries on the next load, with no error toast.userproviderhtmlstorageAPIclick Darkdata-themerepaintsave locallyPUT /prefs204PUT, offlinefailskeep the local theme, retry on next load
The screen changes before any network call. Storage and the account follow; if the 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.

ts
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).

ts
// 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.startViewTransition is missing, just apply the theme.
  • Skip the animation when prefers-reduced-motion: reduce matches. 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

ts
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.

A sequence diagram of the OS switching to dark at sunset while the choice is System. Columns: OS, query, provider, html, chart. The OS turns dark. The media query fires a change event to the provider. The provider sets data-theme to dark on html and passes the new resolvedTheme to the chart. The chart reads token values from html with getComputedStyle, gets them back, and redraws its canvas. A note says that when the choice is Light or Dark, no listener exists, so nothing happens.OSqueryproviderhtmlchartsunset: darkchange eventdata-themeresolvedThemeread tokenstoken valuesredraw canvaschoice Light or Dark:no listener, nothing runs
CSS-only parts follow the attribute for free; only the chart needs the second half of this flow. And none of it runs unless the choice is System.

Keeping tabs in sync

Decision How do other open tabs learn about a change?

The storage event

Each tab listens for window storage events.

When another tab writes the theme key, the event fires with key and newValue.

Pros

  • Free: we already write localStorage.
  • Supported everywhere; fires only in the other tabs, not the writer.

Cons

  • Only for localStorage changes; carries a string.

BroadcastChannel

new BroadcastChannel("theme"); post a message on change; other tabs receive it.

Pros

  • Can carry structured messages, and works from workers too.

Cons

  • A second mechanism next to storage we already write.
  • Doesn't persist: a tab opened later still needs to read storage.

Poll on focus

Re-read storage on visibilitychange or focus.

Pros

  • Simple.

Cons

  • Background tabs stay stale, and a split-screen window that never loses focus never updates.

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.

WhatProblem in dark modePlan
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.
LogoA dark wordmark disappears.Two files, one per theme, switched by the theme attribute (below).
Screenshots and diagramsA 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 avatarsLook fine, but bright ones glare a little.Leave the colors alone. Optionally filter: brightness(0.9) in dark mode. Never invert.
ShadowsA black shadow on a near-black page is invisible.Show elevation with lighter surfaces instead (below).
Canvas chartsColors were read once at draw time; the chart keeps the old theme.Read tokens in JavaScript and redraw on theme change.
Third-party iframesThe 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.

ts
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

WhatMinimum (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:1Headings on --color-bg
UI parts and graphics (1.4.11)3:1Input --color-border, focus ring, icons, chart lines on their surface
Disabled controlsNo minimumStill 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 #0000ff blue 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: more is 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 like Canvas, CanvasText, LinkText and ButtonText. Your theme must step aside: don't fight it with forced-color-adjust: none on 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.
css
@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.

The theme switcher's architecture. Inside a browser tab: the head script reads localStorage and sets data-theme on the html element before the first paint. The html element's attribute selects the values in the token CSS. After hydration, the ThemeProvider holds the choice and the resolved theme; it updates the html element, writes localStorage and the cookie, passes the resolved theme to charts and logos, and saves the choice to the preferences API. The ThemeControl calls setChoice on the provider. The provider listens to matchMedia while the choice is System, and to storage events from other tabs.Browser tabhtml elementdata-themehead scriptbefore first painttoken CSS--color-* per themeThemeProviderchoice, resolvedcharts, logosread resolvedThemeThemeControlmenu, radioslocalStoragethemecookiethemePrefs APIaccountlistens: matchMedia (System only), storage
Everything visual hangs off one attribute on 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.
PartRunsResponsibility
Head scriptDuring parsing, once per page loadRead the choice, resolve it, set data-theme and color-scheme on html.
Token stylesheetAlways (CSS)Map semantic variables to values per theme; the only place with raw colors.
ThemeProviderAfter hydrationHold the choice and resolved theme in React state; apply changes; listen to matchMedia (System only) and storage; write localStorage, the cookie and the account.
ThemeControlIn the header and settingsA three-option control that calls setChoice.
Theme-aware mediaOn changeCharts, large images and embeds that read resolvedTheme and redraw or swap.
Preferences APIServerStore 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.

The theme's data flow. On the left, the server side: the CDN sends the same HTML to everyone; the Next.js server reads the theme cookie and sends HTML with data-theme already set; the preferences API stores the account value and seeds the cookie at sign-in. In the browser: the head script reads storage and the OS and sets data-theme on html; html selects the token CSS; the token values reach every component through color variables. After hydration, the ThemeProvider updates html on change and sends PUT to the preferences API.Server sideBrowserCDN HTMLsame for allNext.jsreads cookiePrefs APIaccounthead scriptstorage + OShtmldata-themetoken CSScolors per themecomponentscolor variablesThemeProviderafter hydrationsetsHTML + data-themeselectsvaluesPUTsign-in
Two roads reach the same attribute: cached pages get it from the head script, per-user pages can get it straight from the server. From the attribute on, it is pure CSS until a write goes back out as a PUT.

Data model

ts
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

http
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?

A single toggle button

One button with aria-pressed, labelled "Dark mode".

Pros

  • One click; tiny.

Cons

  • Only two states. There's no way to go back to System, which is the default most people want.

A radio group of three

Three options in a fieldset with a legend, or role="radiogroup"; arrow keys move the selection.

Pros

  • All three states visible; the checked one is announced.
  • Native input type="radio" gives keyboard behavior for free.

Cons

  • Takes horizontal space; awkward in a crowded header.

A menu button

A button labelled "Theme: System" that opens a menu of three menuitemradio items.

Pros

  • Compact for a header; shows the current choice in the button text.

Cons

  • Two clicks to change; needs the full menu keyboard pattern and focus handling.

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.

tsx
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 #fff shows 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-theme attribute 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.
ts
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

CostBudgetWhy it's fine or what to watch
Head script sizeUnder 1 KBAbout 350 bytes; it ships in every HTML response, uncached on its own.
Head script run timeUnder 1 msOne storage read and one media query. It blocks parsing, so no loops and no JSON.
Layout shift (CLS)0Themes change colors only, never sizes. Watch fonts or borders that differ in width per theme.
Switch timeOne frame for CSS; under 100 ms with chartsStyle recalculation for one attribute is fast; chart redraws are the main cost.
Re-renders on switchOnly theme-aware mediaColors 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:

MinutesWhat to doWhat to say or draw
0 to 5RequirementsAsk about more themes, following the OS, caching and CSP. State the no-flash and contrast numbers.
5 to 10Three statesDraw the choice and the resolved theme. Say "I store the choice, never the color."
10 to 18First paintDraw the load timeline. Explain the inline head script, the CSP hash, and the cookie for per-user pages.
18 to 25TokensShow the two-layer CSS: semantic variables per theme, color-scheme, then images and charts.
25 to 32Runtime and APIClick flow, OS listener only for System, storage event across tabs, the provider and the control.
32 to 35PolishContrast 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?

Think first

Follow-up from the interviewer: product now wants a third theme, high contrast. What changes?

Summary

  • Store the choice (Light, Dark or System), never the painted color. Resolve it from the choice plus prefers-color-scheme every time.
  • A tiny inline script in the head sets data-theme and color-scheme on html before 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 suppressHydrationWarning on html only, 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: currentColor SVGs, logos switched by our attribute (not a picture media query), lighter surfaces for elevation, and charts that read tokens with getComputedStyle and redraw.
  • Switch with transitions disabled, listen to the OS only while the choice is System, sync tabs with the storage event, 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next