Section 3 · Responsive and Robust
Light and Dark with light-dark()
17 min3 min readCode playground
Lesson at a glance
Ship a light and dark theme with color-scheme and light-dark(), add a user override that beats the system setting, and force a section dark without duplicating tokens.
- 1color-scheme: tell the browser what you support
- 2light-dark(): one token, two values
- 3A toggle that beats the system setting
- 4Forcing a section dark
- 5Designing the dark palette
- 6Your turn
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Waypoint’s dark mode was built the classic way: a full set of color tokens in :root, and a second full set inside @media (prefers-color-scheme: dark). It worked until three things happened. Someone added a token to the light set and forgot the dark one. Users asked for a toggle, because their OS is dark but they want the schedule light on a projector. And the design team wanted the hero to be dark in both modes.
Each of these is a one-liner with color-scheme and light-dark().
color-scheme: tell the browser what you support
:root {
color-scheme: light dark;
}This declaration says “this page supports both schemes; use the user’s preference”. The browser responds by rendering its own UI to match: form controls, checkboxes, scrollbars, the default page background and text color. Without it, a dark page still gets bright white <select> menus and scrollbars, which is the most common tell of a hand-made dark mode.
Add the matching meta tag so the browser knows before your CSS arrives, and the page doesn’t flash white on load:
<meta name="color-scheme" content="light dark">light-dark(): one token, two values
light-dark() takes two colors and returns the first when the element’s used color scheme is light and the second when it’s dark:
:root {
color-scheme: light dark;
--surface: light-dark(#ffffff, #0f172a);
--text: light-dark(#1f2937, #e5e7eb);
--card: light-dark(#f8fafc, #1e293b);
--border: light-dark(oklch(90% 0.01 280), oklch(35% 0.02 280));
}
body {
background: var(--surface);
color: var(--text);
}
.session-card {
background: var(--card);
border: 1px solid var(--border);
}Each token is now defined once, with both values side by side. Adding a token without its dark value becomes visibly incomplete in review. light-dark() for colors is Baseline 2024 and works in every current browser. Passing it two images (such as gradients or url()s) only became Baseline in September 2026, and it never accepts lengths or other values, so for those keep a prefers-color-scheme media query for now.
A toggle that beats the system setting
Because everything reads the color scheme, the toggle only has to change one property on the root:
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }With no data-theme, the root keeps light dark and follows the OS. With data-theme="dark", it’s forced dark, and every token follows. The JavaScript for a three-way control is tiny:
const select = document.querySelector('#theme');
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
select.addEventListener('change', () => {
const value = select.value; // 'system' | 'light' | 'dark'
if (value === 'system') {
delete document.documentElement.dataset.theme;
localStorage.removeItem('theme');
} else {
document.documentElement.dataset.theme = value;
localStorage.setItem('theme', value);
}
});Run the restore part in a small inline script in the <head> so the saved theme applies before the first paint.
Forcing a section dark
color-scheme is inherited, and light-dark() resolves against each element’s own value. So the always-dark hero needs one declaration:
.hero {
color-scheme: dark;
background: var(--surface);
color: var(--text);
}Inside the hero, --surface, --text and --card all resolve to their dark values, including the keynote session card nested inside it, while the rest of the page stays light. The same trick works the other way for a light “print preview” panel inside a dark app.
Designing the dark palette
Dark mode isn’t inverted light mode. A few rules that hold up on real projects:
- Avoid pure black. A very dark blue-grey such as
#0f172ais easier on the eyes and leaves room for elevation. - Show elevation with lighter surfaces, not shadows: a card sits on
#1e293b, a popover on a step lighter. Shadows barely show on dark backgrounds. - Lower the chroma of accents. The vivid track colors from the last lesson vibrate on a dark background.
oklch(from var(--track) 72% calc(c * 0.8) h)raises lightness and tones down chroma for dark surfaces. - Recheck contrast in both schemes. A pairing that passes in light mode guarantees nothing in dark.
You don’t have to switch your operating system to test any of this. Chrome’s DevTools can emulate prefers-color-scheme: dark from the Rendering panel, and Firefox has a toggle in its Inspector. Test the three toggle states and the hero in each, and check the form controls: they are the first place a missing color-scheme shows up.
Your turn
The exercise uses the old pattern: tokens plus a prefers-color-scheme override, and no toggle. Rewrite the tokens with light-dark(), wire up data-theme on the root, and make the hero dark in both modes. The checks set data-theme and read the computed colors. Next, you’ll make sure people who navigate with a keyboard can see where they are, in both themes.
Key takeaways
color-scheme: light darktells the browser the page supports both schemes, so form controls, scrollbars and default colors follow the user’s setting.light-dark(A, B)returns A or B depending on the element’s used color scheme, so each token is defined once instead of in two places.- A three-way toggle (system, light, dark) only needs to set
color-schemeon the root; everylight-dark()value follows. - Because
light-dark()resolves per element,color-scheme: darkon one section makes that section dark while the rest of the page stays light.