Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~28 min· topic 10 of 10

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: white that other rules read with var(--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.

theme.csswhole filecss
: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>.

Resolve the theme from choice and systemdiagram
Rendering diagram…

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.

Apply it before the first paintdiagram
Rendering diagram…

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. 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 useMediaQuery subscribed to the change event. Do the same for prefers-reduced-motion.

  2. 2

    Add a high-contrast token set

    Add :root[data-contrast="more"] { --text: #000; --accent: #0033cc; } and set data-contrast from useMediaQuery("(prefers-contrast: more)"). Which component code changes? (None: only CSS and one attribute.)

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

A theme switcher: CSS variables, system preference and a saved choice New tab

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

Press Run to see the page.

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>
Keyboard shortcuts and a command palette New tab

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

Press Run to see the page.

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
No flash: the inline head script (not live: goes in index.html or the root layout)html
<!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>
The same with next-themes in Next.js (not live: install next-themes)tsx
// 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.

terminal
$ Reload with a saved dark theme
── what you'll see ──
The page appears white for a moment (until the JavaScript bundle has loaded and run), then switches to dark.

Break #2

Single-key shortcuts that fire while typing

In useShortcut, remove the typing check.

terminal
$ Console (type "no" in the palette search)
── what you'll see ──
ran: New document
(typing the letter n in a text box runs a command)

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 !important on *, change the theme, force a reflow, then remove it (next-themes calls this disableTransitionOnChange). 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's color-scheme, so a whole theme can be written as one set of declarations when color-scheme is set per theme. Check browser support before relying on it.

  • ▸

    A useShortcut hook that receives a new handler every render re-subscribes after each render. Keep the latest handler in a ref (or use useEffectEvent in React 19.2, Topic 7.9) so the listener is added once.

Remember this

  1. 1

    CSS variables are the theme. A CSS custom property (--bg: #fff) is a named value that any rule can read with var(--bg). Define one set of tokens on :root and 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 set color-scheme: light dark (or per theme) so the browser draws native controls, scrollbars and form fields in matching colours.

  2. 2

    Follow the system, allow an override. The media query prefers-color-scheme: dark reflects 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 with matchMedia(query) through useSyncExternalStore, subscribing to its change event, 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. 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 and matchMedia, and sets data-theme on <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 add suppressHydrationWarning on <html>). Libraries like next-themes ship exactly this script.

  4. 4

    Reduced motion and other preferences. prefers-reduced-motion: reduce means 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) and prefers-reduced-data. The same useMediaQuery hook reads them all.

  5. 5

    Keyboard shortcuts. Listen for keydown on window in an effect (with cleanup). Rules: ignore single-letter shortcuts while the user types in an input, textarea or contenteditable element; use metaKey on macOS and ctrlKey elsewhere for combinations (check both); don't override essential browser and screen reader keys; call preventDefault() only for keys you handle; and make every shortcut discoverable (show it in menus and tooltips with <kbd>, and expose aria-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. 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. The cmdk library is a popular headless version.

Explain it without notes

01

How would you implement light, dark and system themes in a React app?

02

What causes a flash of the wrong theme and how do you prevent it?

03

How do you read and react to prefers-color-scheme and prefers-reduced-motion in React?

04

What are the rules for good keyboard shortcuts?

Practice

01

Write a useTheme() hook that returns { choice, theme, setChoice } and can be used from any component (hint: context or an external store).

02

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.