EduFlow
Modern CSS Mastery
0%

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.

  1. 1color-scheme: tell the browser what you support
  2. 2light-dark(): one token, two values
  3. 3A toggle that beats the system setting
  4. 4Forcing a section dark
  5. 5Designing the dark palette
  6. 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

theme.css
: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:

HTML
<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:

CSS
: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:

CSS
: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:

theme-toggle.js
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.

The root element has a color-scheme of light, dark, or both following the OS. Most elements inherit it. The hero sets color-scheme dark, so inside the hero every light-dark token returns its dark value, while the rest of the page returns light values. :root color-scheme system / light / dark .schedule inherits light-dark(A, B) → A .hero color-scheme: dark hero card inherits dark light-dark(A, B) → B Same tokens everywhere; each element resolves them with its own scheme.
The color scheme is resolved per element, then light-dark() picks a value

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:

CSS
.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 #0f172a is 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 dark tells 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-scheme on the root; every light-dark() value follows.
  • Because light-dark() resolves per element, color-scheme: dark on one section makes that section dark while the rest of the page stays light.

Further reading

Check your understanding

3 questions

Question 1 of 3

You write --surface: light-dark(white, #0f172a) and use it on body, but the page stays white in dark mode. What’s missing?

Question 2 of 3

The Waypoint hero should always look dark, even in light mode. Its colors come from light-dark() tokens. What’s the smallest change?

Question 3 of 3

What’s the job of <meta name="color-scheme" content="light dark"> in the HTML head?

Practice

Code playground

One set of tokens, three themes

Rewrite the theme so that:

  1. The root declares support for both schemes, and --surface, --text and --card are each defined once with light-dark() (light values: #ffffff, #1f2937, #f8fafc; dark: #0f172a, #e5e7eb, #1e293b). Remove the media query.
  2. data-theme="light" or data-theme="dark" on <html> forces that scheme; with no attribute, the page follows the system.
  3. The .hero is dark in every mode, using the same tokens (no hero-specific colors).

The checks set data-theme on <html> and read computed colors.

<section class="hero">
  <h1>Waypoint Conf 2026</h1>
  <article class="session-card" id="keynote">Keynote · The Platform Is the Framework</article>
</section>
<main class="schedule">
  <article class="session-card" id="regular">Shipping View Transitions</article>
</main>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • data-theme=“dark” makes the page dark
  • data-theme=“light” makes the page light
  • The root’s color-scheme follows the toggle
  • With no data-theme, the root supports both schemes
  • The hero and its card stay dark in light mode