Topic 24.10
Theming, Dark Mode and User Preferences
In one line
Themes are sets of CSS variables switched by one attribute on <html>; the app follows the system setting (prefers-color-scheme) unless the user picks light or dark, remembers that choice, applies it before the first paint so the page never flashes the wrong colours, respects reduced motion, and offers keyboard shortcuts and a command palette for power users.
Think of it like this
Lights in a smart home. By default they follow the sun: bright in the day, dim at night (follow the system). You can also flip a switch to force them on or off, and the house remembers your choice tomorrow. A good house sets the lights *before* you walk in, not after you've stumbled in the dark for a second (no flash). And some people prefer no flashing disco effects at all (reduced motion).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- CSS custom property
- A named CSS value like
--bg: whitethat other rules read withvar(--bg). - Design token
- A named design decision (a colour, a spacing size) used everywhere instead of raw values.
- prefers-color-scheme
- A media query that tells you whether the user's system is set to light or dark mode.
- Resolved theme
- The theme actually shown: the user's choice, or the system's when they chose "system".
- Flash of the wrong theme
- The default colours showing briefly before JavaScript switches to the user's theme.
- color-scheme
- A CSS property that tells the browser which themes the page supports, so it styles native controls to match.
- prefers-reduced-motion
- A media query that is true when the user asked the system to minimise animation.
- Command palette
- A searchable list of all actions, opened with a shortcut like Ctrl+K.
Step by step
01Define tokens and a dark set
All colours become variables on :root; a [data-theme="dark"] block redefines them. Components never mention a hex code.
:root {
color-scheme: light;
--surface: #ffffff;
--text: #212529;
--accent: #1c7ed6;
}
:root[data-theme="dark"] {
color-scheme: dark;
--surface: #1a1b1e;
--text: #e9ecef;
--accent: #74c0fc;
}
body { background: var(--surface); color: var(--text); }02Resolve the theme from choice and system
The choice is light, dark or system. Read the system setting with matchMedia through useSyncExternalStore, then set data-theme on <html>.
03Apply it before the first paint
An inline script in <head> runs before the body is drawn and sets the attribute. React then takes over for later changes.
04Respect motion and contrast preferences
Use the same media-query hook for prefers-reduced-motion and prefers-contrast, and keep CSS fallbacks in @media blocks so it works without JavaScript too.
05Add shortcuts and a palette
One useShortcut hook with a global listener and cleanup. The palette is a dialog with a combobox of commands.
Try it yourself
- 1
Emulate dark mode
Open your browser's DevTools, find Rendering (in the ⋮ menu, More tools) and set "Emulate CSS media feature prefers-color-scheme" to dark. With the choice on system, the theme example switches live, because
useMediaQuerysubscribed to the change event. Do the same for prefers-reduced-motion. - 2
Add a high-contrast token set
Add
:root[data-contrast="more"] { --text: #000; --accent: #0033cc; }and setdata-contrastfromuseMediaQuery("(prefers-contrast: more)"). Which component code changes? (None: only CSS and one attribute.) - 3
Add a shortcut to the palette
Give "Open settings" the shortcut
,with Ctrl/⌘ ((e.ctrlKey || e.metaKey) && e.key === ","), show it in the list with<kbd>, and predict whether it should fire inside inputs.
Code & diagrams
The first line depends on your system setting: the checker's browser is in light mode. Choose dark and the whole page changes through one attribute. Choose system and switch your OS to dark mode: the page follows live. In the preview, localStorage is in memory and resets on each Run.
Preview
Console
Nothing logged yet.Expected console output
choice system -> theme light, reduced motion false
choice dark -> theme dark, reduced motion false
saved: dark, <html data-theme=dark>N runs New document, but only when you're not typing: inside the palette's search box the same key just types. Ctrl+K (or ⌘K) opens the palette with focus in the search; the arrows and Enter run a command, and the dialog closes and returns focus. Click inside the preview first, then try the keys.
Preview
Console
Nothing logged yet.Expected console output
ran: New document
palette open: true, focus in search: true
matches for 'o': 3
ran: Toggle dark mode
palette open: false<!doctype html>
<html lang="en">
<head>
<meta name="color-scheme" content="light dark" />
<script>
// Runs before the body is painted. Keep it tiny and dependency-free.
(function () {
try {
var choice = localStorage.getItem("theme") || "system";
var dark = choice === "dark" ||
(choice === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/theme.css" />
</head>
<body><div id="root"></div></body>
</html>// app/layout.tsx
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// next-themes sets the attribute before hydration, so React would see a mismatch: allow it here only.
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
// Any client component:
// "use client";
// const { theme, setTheme, resolvedTheme } = useTheme();Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Applying the theme only in a React effect
Delete the inline head script and rely on the useEffect in ThemeSwitcher to set data-theme on a server-rendered or slow-loading page.
Break #2
Single-key shortcuts that fire while typing
In useShortcut, remove the typing check.
Myth vs fact
Myth
Dark mode is just inverting colours.
Fact
Dark themes need their own palette: lower-contrast surfaces, lighter accent shades, less saturated colours and adjusted shadows. Inverting with filter: invert() also inverts photos and brand colours.
Myth
Storing the theme in React context is enough.
Fact
Context only exists after React runs. The first paint must already have the right theme, which needs a pre-React script or server rendering from a cookie.
Myth
Users who want dark mode will toggle it in your app.
Fact
Most never touch app settings. Following prefers-color-scheme by default respects the choice they already made in their OS.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Switching themes by attribute + CSS variables costs one style recalculation and no React renders. Theme objects in React context (CSS-in-JS) re-render every consumer on change and can't be applied before hydration without extra work, which is one reason many teams moved to CSS-variable themes.
- ▸
To avoid every element animating colours at the moment of switching, temporarily add a class that sets
transition: none !importanton*, change the theme, force a reflow, then remove it (next-themes calls thisdisableTransitionOnChange). Or use the View Transitions API for a deliberate cross-fade. - ▸
light-dark(#fff, #111)is a CSS function that picks a value based on the element'scolor-scheme, so a whole theme can be written as one set of declarations whencolor-schemeis set per theme. Check browser support before relying on it. - ▸
A
useShortcuthook that receives a new handler every render re-subscribes after each render. Keep the latest handler in a ref (or useuseEffectEventin React 19.2, Topic 7.9) so the listener is added once.
Remember this
- 1
CSS variables are the theme. A CSS custom property (
--bg: #fff) is a named value that any rule can read withvar(--bg). Define one set of tokens on:rootand a second set under a selector such as:root[data-theme="dark"]. Components only ever use the tokens (background: var(--bg)), so switching theme is a single attribute change on<html>: no React re-render of every component, no prop drilling, and it works for CSS you don't own. Name tokens by role (--surface,--text-muted,--accent), not by colour (--blue). Also setcolor-scheme: light dark(or per theme) so the browser draws native controls, scrollbars and form fields in matching colours. - 2
Follow the system, allow an override. The media query
prefers-color-scheme: darkreflects the operating system setting. Store the user's choice as one of three values,"light","dark"or"system", and compute the resolved theme: the choice, or the system value when the choice is"system". In React, read media queries withmatchMedia(query)throughuseSyncExternalStore, subscribing to itschangeevent, so the app updates live when the user switches the OS to dark mode at sunset. Persist the choice (localStorage, or a cookie if the server renders the page) and offer a three-way control, not just a toggle. - 3
No flash of the wrong theme. React renders after its JavaScript has downloaded and run. If the theme is applied in an effect, the browser first paints the default (light) colours, then switches: a white flash for dark-mode users on every load. Fix it before React: a tiny inline
<script>in<head>(it runs before the body is painted) reads the saved choice andmatchMedia, and setsdata-themeon<html>synchronously. With server rendering, the server can read a theme cookie and render the attribute directly. Because the attribute is set before hydration, React must not fight it (Next.js apps addsuppressHydrationWarningon<html>). Libraries likenext-themesship exactly this script. - 4
Reduced motion and other preferences.
prefers-reduced-motion: reducemeans the user asked the OS to minimise animation (motion can trigger dizziness or migraines). Respect it in CSS (@media (prefers-reduced-motion: reduce) { ... transition: none }) and in JavaScript (instant scrolling, no parallax, simpler transitions). Other useful media features:prefers-contrast: more,forced-colors: active(Windows High Contrast, where your colours are replaced, so don't rely on background colours alone to show state) andprefers-reduced-data. The sameuseMediaQueryhook reads them all. - 5
Keyboard shortcuts. Listen for
keydownonwindowin an effect (with cleanup). Rules: ignore single-letter shortcuts while the user types in aninput,textareaorcontenteditableelement; usemetaKeyon macOS andctrlKeyelsewhere for combinations (check both); don't override essential browser and screen reader keys; callpreventDefault()only for keys you handle; and make every shortcut discoverable (show it in menus and tooltips with<kbd>, and exposearia-keyshortcuts). WCAG 2.1 (criterion 2.1.4) requires single-character shortcuts to be turn-off-able or remappable, or active only when a component has focus. - 6
A command palette (opened with Ctrl+K / ⌘K in many apps) is a searchable list of every action: a dialog containing a combobox (Topic 24.5) whose options are commands. Filter as the user types, move with the arrow keys, run with Enter, close with Escape, and return focus where it was (the native
<dialog>does this, Topic 24.8). It makes every feature reachable from the keyboard, and it's a great home for listing shortcuts. Thecmdklibrary is a popular headless version.
Explain it without notes
How would you implement light, dark and system themes in a React app?
What causes a flash of the wrong theme and how do you prevent it?
How do you read and react to prefers-color-scheme and prefers-reduced-motion in React?
What are the rules for good keyboard shortcuts?
Practice
Write a useTheme() hook that returns { choice, theme, setChoice } and can be used from any component (hint: context or an external store).
Add Escape and a click outside to close the command palette, and make Ctrl+K toggle it.
Trade-offs
- ↔
CSS-variable themes are fast and framework-independent but need a pre-React script to avoid flashing; theme objects in context are convenient in CSS-in-JS but re-render consumers and are harder to apply before hydration.
- ↔
Keyboard shortcuts make power users fast but can clash with assistive technology and typing; keep them few, discoverable, scoped and optional, and offer a command palette as the searchable alternative.
Done when you can
I can build a theme from CSS variables switched by one attribute.
I can follow the system theme live and let users override it, saving the choice.
I can prevent the flash of the wrong theme with an inline head script or server rendering.
I can respect prefers-reduced-motion and other preferences with a useMediaQuery hook.
I can add safe global keyboard shortcuts and a command palette.