light-dark(): Two-Mode Colors in One Declaration
Dark mode CSS has a shape: define a color, then redefine it inside a media query or theme class. Every themed property, twice, in two places. light-dark() folds the pair into the declaration itself:
:root {
color-scheme: light dark; /* REQUIRED - light-dark() is inert without it */
}
.card {
background: light-dark(#ffffff, #0f172a);
color: light-dark(#0f172a, #e2e8f0);
border: 1px solid light-dark(#e2e8f0, #1e293b);
}
The function resolves to its first value in light mode, second in dark - following the user's OS preference by default. Both halves of every color decision sit side by side, which sounds cosmetic until you maintain a theme: no more hunting the dark twin of a rule three files away, no more colors that got a light-mode tweak and silently kept a stale dark value.
color-scheme is doing quiet heavy lifting
That required first line is a feature by itself. Declaring color-scheme: light dark tells the browser your page supports both, and the browser then renders its own UI accordingly in dark mode: scrollbars, form control chrome, date pickers, the default canvas background, even the color of visible focus rings. A dark theme with a glaring white native scrollbar is a missing color-scheme declaration - one line, fixed. (Pair it with accent-color and native form controls are fully themed in two declarations total.)
Supporting a manual toggle
light-dark() follows the used color scheme, which you can override per subtree - so the OS-preference default and a user toggle compose cleanly:
:root { color-scheme: light dark; } /* default: follow the OS */
[data-theme="light"] { color-scheme: light; } /* user forced light */
[data-theme="dark"] { color-scheme: dark; } /* user forced dark */
Set the data attribute from your toggle and every light-dark() in the tree flips with it - no separate variable sets. (The full toggle wiring - localStorage, no-flash SSR, the end-to-end pattern - deserves its own writeup.)
Where it fits vs custom properties
light-dark() takes only colors and only two modes. If your theming has more axes (brand variants, high-contrast, density) or themes non-color values, custom-property swapping remains the general mechanism - and the two combine happily: --surface: light-dark(#fff, #0f172a) defines the pair once, everything else consumes the variable.
Support: Chrome 123+, Firefox 120+, Safari 17.5+ - green since mid-2024. For older browsers, the custom-property fallback pattern covers you; for new projects, light-dark() plus color-scheme is the least ceremony dark mode has ever required.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.