Command Palette

Search for a command to run...

Hectal
PHASE 11Advanced ~27 min· topic 3 of 8

Topic 11.3

Theming and Dark Mode

In one line

A theme is a set of custom properties (colour tokens) that every component reads; dark mode swaps those values, color-scheme tells the browser to draw its own parts dark too, light-dark() picks a value per scheme, and prefers-color-scheme follows the user's system setting.

Think of it like this

A room with a light switch and a dimmer. The furniture stays where it is; only the lighting changes. A theme works the same way: your layout and components stay the same, and only a small set of named colours changes. Flip the switch (dark mode) and every wall, chair and lamp looks different at once, because they all get their colour from the same lights.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Theme
A set of values (mostly colours) that controls how the whole site looks, which you can swap as a group.
Custom property
A CSS variable you name yourself with two dashes, like --bg, and read with var(--bg).
Design token
A named design value, like --accent or --radius, used everywhere instead of raw values.
Semantic token
A token named by its job (--text, --danger) rather than its look (--red-600).
`prefers-color-scheme`
A media feature that tells CSS whether the user's system is set to light or dark.
`color-scheme`
A property that tells the browser which schemes your page supports, so it draws its own parts (canvas, controls, scrollbars) to match.
`light-dark()`
A colour function that returns its first colour in light mode and its second in dark mode.
Canvas
The background the browser paints behind your whole page.
Theme flash
The page appearing in the wrong theme for a moment while it loads, before the right one is applied.

Step by step

01Define tokens and use them everywhere

Put the colours on :root (the <html> element) and make components read them. No component mentions a raw colour any more.

theme.csswhole filecss
:root {
  --bg: #ffffff;
  --text: #1f2937;
  --accent: #4f46e5;
}
body { background: var(--bg); color: var(--text); }
.btn { background: var(--accent); color: var(--bg); }

02Follow the system setting

Redefine the same tokens when the user prefers dark. Every component updates, because they all read the tokens. Add color-scheme so the browser's own parts match.

theme.csswhole filecss
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #111827;
    --text: #e5e7eb;
    --accent: #a5b4fc;
  }
}

03Shorten it with light-dark()

light-dark() picks a colour based on the element's colour scheme, so each token is one line and the media query disappears.

theme.csswhole filecss
:root {
  color-scheme: light dark;
  --bg: light-dark(#ffffff, #111827);
  --text: light-dark(#1f2937, #e5e7eb);
  --accent: light-dark(#4f46e5, #a5b4fc);
}

04Add a manual switch

Let the user override the system. A data-theme attribute on <html> forces one scheme. Because the tokens use light-dark(), changing color-scheme is all that's needed.

theme.csswhole filecss
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

05Avoid the flash on load

If the saved choice is applied by a script at the end of the body, the page first paints in the default theme and then jumps. Put a tiny inline script at the top of the <head> so the attribute is set before the first paint.

index.htmlwhole filehtml
<head>
  <meta name="color-scheme" content="light dark">
  <script>
    try {
      const t = localStorage.getItem("theme");
      if (t) document.documentElement.dataset.theme = t;
    } catch (e) {}
  </script>
  <link rel="stylesheet" href="theme.css">
</head>

06How the pieces decide the final colour

The user's setting or your switch sets color-scheme; color-scheme decides which side of light-dark() wins; the tokens feed every component.

How the pieces decide the final colourdiagram
Rendering diagram…

Try it yourself

  1. 1

    Add a token

    In the first example, add a --border token to both themes and use it for the .app border. Predict what the border looks like in each theme before you click.

  2. 2

    Remove color-scheme

    In the second example, delete .force-dark { color-scheme: dark; }. Predict the dark panel's background and its input's look. Run and check the console.

  3. 3

    Emulate dark mode

    Open DevTools on the third example, find the Rendering panel and emulate prefers-color-scheme: dark. Predict what the message will say first.

Code & diagrams

Tokens and a theme switch New tab

One attribute change swaps three tokens, and every rule that reads them updates. The script clicks the button once so you can see both themes in the console; click it yourself to switch back.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

light rgb(255, 255, 255)
dark rgb(17, 24, 39)
light-dark() and color-scheme, side by side Baseline 2024+ New tab

The same declarations give two different results, because `light-dark()` reads each element's `color-scheme`. Look at the inputs: in the dark panel the browser draws the control dark too.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

panel force-light rgb(248, 250, 252)
panel force-dark rgb(15, 23, 42)
Follow the system setting New tab

Change your operating system's appearance setting (or use DevTools: Rendering, then "Emulate CSS media feature prefers-color-scheme") and the box and message update without a reload.

Preview

Press Run to see the page.

Console

Nothing logged yet.

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

Use light-dark() without color-scheme

In the second example, remove both .force-light and .force-dark rules, then emulate dark mode in DevTools.

terminal
$ Console and page
── what you'll see ──
Both panels log rgb(248, 250, 252): they stay light even in dark mode.

Break #2

Apply the saved theme too late

Restore the saved theme with a script at the end of <body> instead of in the <head>.

terminal
$ Reload with dark saved
── what you'll see ──
The page appears light for a moment, then switches to dark: a visible flash on every page load.

Myth vs fact

Myth

Dark mode means inverting all the colours.

Fact

Inversion breaks images, brand colours and contrast. Good dark themes choose new values per token: dark grey surfaces, softened text and lighter accents.

Myth

prefers-color-scheme is enough for a complete dark mode.

Fact

It changes your CSS, but the browser's own canvas, scrollbars and form controls stay light unless you also set color-scheme.

Myth

Custom properties are slow, so themes should be compiled to plain colours.

Fact

Reading a custom property is cheap. Changing one high in the tree restyles everything that inherits it, which is a normal style recalculation, fine for a theme switch.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    light-dark() reached all major browsers in May 2024 (Chrome 123, Firefox 120, Safari 17.5). For older browsers, keep the prefers-color-scheme media-query version as the base, or ship both.

  • ▸

    Custom properties are guaranteed-invalid at computed-value time when they hold a value the property can't use: color: var(--x) with --x: 20px makes color behave as unset (inherit), not as "ignored". DevTools shows the declaration as valid, which confuses people.

  • ▸

    @property --accent { syntax: "<color>"; inherits: true; initial-value: #4f46e5; } registers a typed custom property (Baseline 2024). Registered colours can be animated and transitioned, so a theme switch can fade instead of jumping.

  • ▸

    accent-color: var(--accent) themes checkboxes, radios, range sliders and progress bars with one property, and the browser picks a readable checkmark colour automatically.

Remember this

  1. 1

    The building blocks are custom properties (CSS variables, Phase 3): --bg: #ffffff; defines one, var(--bg) reads it. A theme is a set of design tokens: named values like --bg, --text, --muted, --accent and --border. Components never use raw colours; they use tokens: background: var(--bg). Custom properties inherit, so redefining the tokens on one element re-themes everything inside it.

  2. 2

    Name tokens by role, not by colour. --accent can be indigo in light mode and pale indigo in dark mode; a token called --indigo-600 that later holds a pale colour is a lie. Many systems use two levels: primitive tokens (--indigo-600: #4f46e5) and semantic tokens that point at them (--accent: var(--indigo-600)). Themes only change the semantic level.

  3. 3

    The user's operating system has a light or dark setting. CSS reads it with the media query @media (prefers-color-scheme: dark) { ... } (Phase 9). The usual pattern: define light tokens on :root, then redefine them inside that media query. JavaScript can read the same setting with matchMedia("(prefers-color-scheme: dark)").matches.

  4. 4

    Your CSS isn't the only thing that draws. The browser draws the page background (the canvas), scrollbars, form controls, and the default text colour. The **color-scheme** property tells it which schemes your page supports: :root { color-scheme: light dark; } means "I support both; use the user's preference". In dark mode the browser then paints a dark canvas, dark scrollbars and dark <input> and <select> controls. Without it, you get a dark page with glaring white form controls. The <meta name="color-scheme" content="light dark"> tag says the same thing before the CSS has even loaded, which avoids a white flash.

  5. 5

    **light-dark(a, b)** is a colour function that returns a when the element's used colour scheme is light and b when it is dark: --bg: light-dark(#ffffff, #111827);. It reads color-scheme, so it only works when color-scheme allows dark (light dark or dark). With it, one declaration replaces a whole media-query block, and setting color-scheme: dark on one element makes that element dark even if the page is light. It only accepts colours (Baseline 2024).

  6. 6

    Most sites also give users a theme switch, because the system setting isn't always what people want on your site. The common recipe: a data-theme="light" or "dark" attribute on <html>, CSS that sets color-scheme (and token values) for each, a button that changes the attribute, and localStorage to remember the choice. To avoid a flash of the wrong theme on load, a tiny script in the <head> sets the attribute before the page is painted.

  7. 7

    Dark mode is a design task, not "invert the colours". Pure black backgrounds with pure white text are harsh; use a dark grey like #111827 and slightly dimmed text. Shadows don't show on dark backgrounds, so use lighter surface colours for raised elements. Saturated brand colours often need lighter versions to keep contrast (Topic 11.7). And images may need filter: brightness(.9) or separate dark versions with <picture> and media="(prefers-color-scheme: dark)".

Explain it without notes

01

How would you structure the CSS for a site that supports light and dark themes?

02

What does the color-scheme property do, and why is it needed even if you write a dark theme yourself?

03

What does light-dark() do, and what does it depend on?

04

How do you build a theme toggle that remembers the user's choice without a flash of the wrong theme?

Practice

01

Write tokens for a light and a dark theme with light-dark() and use them on body and on .card (which should be a slightly different surface colour).

02

Write CSS so a single <aside class="promo"> is always dark, even on a light page, including its form controls.

03

Write the JavaScript for a toggle button #theme that switches data-theme on <html> between light and dark and saves it.

Trade-offs

  • ↔

    Following only the system setting needs no JavaScript, but users can't choose a different theme for your site; a toggle needs a little script and storage.

  • ↔

    light-dark() makes tokens compact, but it only handles colours and two schemes; images, shadows and extra themes (high contrast, sepia) still need attributes or media queries.

Done when you can

  • I can define semantic colour tokens and use them in components.

  • I can switch tokens with prefers-color-scheme.

  • I know what color-scheme changes and always set it.

  • I can write tokens with light-dark().

  • I can build a remembered theme switch without a flash on load.