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 withvar(--bg). - Design token
- A named design value, like
--accentor--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.
: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.
: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.
: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.
: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.
<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.
Try it yourself
- 1
Add a token
In the first example, add a
--bordertoken to both themes and use it for the.appborder. Predict what the border looks like in each theme before you click. - 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
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
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
Console
Nothing logged yet.Expected console output
light rgb(255, 255, 255)
dark rgb(17, 24, 39)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
Console
Nothing logged yet.Expected console output
panel force-light rgb(248, 250, 252)
panel force-dark rgb(15, 23, 42)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
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.
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>.
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 theprefers-color-schememedia-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: 20pxmakescolorbehave asunset(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
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,--accentand--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
Name tokens by role, not by colour.
--accentcan be indigo in light mode and pale indigo in dark mode; a token called--indigo-600that 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
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 withmatchMedia("(prefers-color-scheme: dark)").matches. - 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
**
light-dark(a, b)** is a colour function that returnsawhen the element's used colour scheme is light andbwhen it is dark:--bg: light-dark(#ffffff, #111827);. It readscolor-scheme, so it only works whencolor-schemeallows dark (light darkordark). With it, one declaration replaces a whole media-query block, and settingcolor-scheme: darkon one element makes that element dark even if the page is light. It only accepts colours (Baseline 2024). - 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 setscolor-scheme(and token values) for each, a button that changes the attribute, andlocalStorageto 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
Dark mode is a design task, not "invert the colours". Pure black backgrounds with pure white text are harsh; use a dark grey like
#111827and 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 needfilter: brightness(.9)or separate dark versions with<picture>andmedia="(prefers-color-scheme: dark)".
Explain it without notes
How would you structure the CSS for a site that supports light and dark themes?
What does the color-scheme property do, and why is it needed even if you write a dark theme yourself?
What does light-dark() do, and what does it depend on?
How do you build a theme toggle that remembers the user's choice without a flash of the wrong theme?
Practice
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).
Write CSS so a single <aside class="promo"> is always dark, even on a light page, including its form controls.
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-schemechanges and always set it.I can write tokens with
light-dark().I can build a remembered theme switch without a flash on load.