Dark mode no longer needs a JavaScript class-toggler and two copies of every color rule. light-dark() and color-mix() do it natively — if you set one property most guides skip.

Quick Answer
Set color-scheme: light dark on :root, then write your colors using light-dark(lightValue, darkValue). The browser automatically picks the right value based on the user's prefers-color-scheme setting — no JavaScript, no duplicated CSS rule blocks, and no flash of the wrong theme.
Dark mode used to mean one of two things: a @media (prefers-color-scheme: dark) block duplicating every color declaration, or a JavaScript-driven class on <html> toggled by a button and persisted to localStorage. Both still work in 2026, but neither is the simplest path anymore. This guide covers the modern native approach first, then the manual toggle for when users need to override the system setting.
Quick Answer
prefers-color-scheme is a media feature that reflects the operating system's light/dark setting. It's been supported in every major browser since 2019 and is the foundation both the classic @media approach and the newer light-dark() function build on.
The classic pattern — still perfectly valid, and worth knowing even if you move to light-dark() for new code — defines default (light) values on :root and overrides them inside a media query:
/* The old, pre-light-dark() way — still valid, but means duplicating every color rule inside a media query */:root { --bg: #ffffff; --text: #1a1a1a;} @media (prefers-color-scheme: dark) { :root { --bg: #121212; --text: #e8e8e8; }} body { background: var(--bg); color: var(--text);}This works everywhere and is easy to reason about, but every additional color you add means updating it in two places — the default block and the media query — which is exactly the duplication light-dark() removes.
| Approach | Requires JS | Colors defined once |
|---|---|---|
| JS class-toggle + two rule sets | ✓ Yes | ✗ Duplicated per theme |
| @media (prefers-color-scheme) | ✗ No | ✗ Duplicated per theme |
| light-dark() + color-scheme | ✗ No (opt-in JS only for a toggle) | ✓ One declaration per color |
The practical win of light-dark()isn't that it does something impossible before — it's that every color is declared exactly once, at the point of use, instead of split across a base rule and a media-query override that can silently drift out of sync as a codebase grows.
Quick Answer
light-dark(lightValue, darkValue) returns the first argument in light mode and the second in dark mode — but it only responds to prefers-color-scheme if the element has color-scheme: light dark set. This is the single most commonly missed step.
:root { color-scheme: light dark; /* required for light-dark() to respond to prefers-color-scheme at all */} body { background: light-dark(#ffffff, #121212); color: light-dark(#1a1a1a, #e8e8e8);} .card { background: light-dark(#f4f4f4, #1e1e1e); border: 1px solid light-dark(#e0e0e0, #2e2e2e);}/* No @media block needed — every light-dark() call updates together whenever the preference changes. */The gotcha: light-dark() reads the color-scheme property in effect on the element, not prefers-color-scheme directly. Without color-scheme: light dark set somewhere in the element's ancestry, every light-dark()call just returns its light branch, permanently, regardless of the user's OS setting.
It's also worth setting the color-scheme meta tag (or relying on the CSS property, which does the same job) so browser-native UI — form controls, scrollbars, the default canvas color painted before your stylesheet loads — matches the active theme instead of flashing light-themed chrome on a dark page.
<!-- In <head>, alongside (or instead of) the CSS property below --><meta name="color-scheme" content="light dark">Quick Answer
color-mix(in oklch, colorA percentage, colorB percentage) blends two colors in a given color space — mixing a brand color with white or black generates lighter or darker variants on the fly, instead of hand-picking a separate hex value for every hover, active, and disabled state per theme.
Combined with light-dark(), this removes one of the more tedious parts of building a dark theme: manually re-deriving every interactive state color for the dark palette. One base brand color plus color-mix()generates both themes' hover states consistently:
:root { --brand: #4338ca; color-scheme: light dark;} .button { background: var(--brand);}.button:hover { /* Lighten in light mode, darken in dark mode — one line, no separate hover color to maintain per theme */ background: light-dark( color-mix(in oklch, var(--brand) 85%, white), color-mix(in oklch, var(--brand) 85%, black) );}Mixing in oklch (rather than the legacy srgb space) generally produces more perceptually even lightening/darkening — the CSS Color Palette generator has a live OKLCH picker if you want to see how a base color's shade ramp looks before wiring it into CSS Color Palette, and the CSS Theme Generator builds a full light/dark custom-property set from a single brand color.
Quick Answer
Let color-scheme: light dark follow the OS by default, then have a toggle button set a data-theme attribute that forces color-scheme to a single explicit value — every existing light-dark() call automatically respects the override with zero other changes.
This is the part that surprises people coming from the class-toggle era: you don't need to rewrite any color declarations to add a manual override. Because light-dark() reads color-scheme, forcing that one property is enough to redirect every call site at once.
:root { color-scheme: light dark; /* default: follow the OS */} /* A toggle button sets this attribute on <html> and persists the choice (e.g. localStorage) — the only JS involved */html[data-theme="light"] { color-scheme: light; }html[data-theme="dark"] { color-scheme: dark; } /* Every light-dark() call site needs zero changes — it now resolves off the forced color-scheme instead of the OS setting */body { background: light-dark(#ffffff, #121212); color: light-dark(#1a1a1a, #e8e8e8);}The only JavaScript left in the whole system is a few lines to toggle the data-theme attribute on click and persist the choice to localStorage — none of it touches color values directly.
Quick Answer
Dark mode and contrast preference are separate concerns — also check prefers-contrast for users who need stronger contrast, and use forced-color-adjust: none sparingly for decorative elements that would otherwise disappear under Windows High Contrast Mode.
Avoid pure black (#000) backgrounds with pure white (#fff) text — the extreme contrast causes a glow/halation effect for many readers, especially on OLED displays, and can be genuinely uncomfortable for people with astigmatism or light sensitivity. A dark grey in the #121212–#1e1e1erange with an off-white foreground is the standard production pattern, and it's exactly what most native OS dark themes use for the same reason.
/* Respect reduced/increased contrast preferences too — dark mode and contrast preference are separate axes */@media (prefers-contrast: more) { :root { --border-opacity: 1; }} /* Windows High Contrast Mode overrides most author colors — opt specific decorative elements out if they'd disappear */.decorative-glow { forced-color-adjust: none;}Covered above, but it's the single most common reason light-dark()“doesn't work” — it silently always returns the light value with no error, which makes it easy to miss during testing if you only check light mode.
If a manual toggle is implemented purely by adding a class after JavaScript runs, there's a brief flash of the default theme before the script executes. Setting the theme attribute as early as possible — ideally via a tiny blocking inline script in <head> before any content paints, reading the saved preference from localStorage — avoids the flash. color-scheme: light darkon its own doesn't have this problem, since the browser applies it before your JS runs at all.
Even without using light-dark(), setting color-schemematters — it's what makes browser-native form controls, scrollbars, and the default page background match your theme instead of standing out as an obviously light-themed element on an otherwise dark page.
| Feature | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
| prefers-color-scheme | 76 (2019) | 12.1 (2019) | 67 (2019) |
| color-mix() | 111 (2023) | 16.2 (2022) | 113 (2023) |
| light-dark() | 123 (2024) | 17.5 (2024) | 120 (2023) |
All three are Baseline widely available by 2026. prefers-color-scheme alone is safe for essentially any audience; light-dark() and color-mix() are safe for any project not specifically supporting browser versions from before 2024.
How do you add dark mode with pure CSS in 2026?
Set color-scheme: light dark on the root, then write colors with light-dark(lightValue, darkValue) — the browser resolves each one automatically based on prefers-color-scheme.
Why doesn't light-dark() switch themes even though I'm using it?
It resolves based on the color-scheme property, not prefers-color-scheme directly. Without color-scheme: light dark set, it always returns the light branch.
What does the CSS color-scheme property actually do?
It declares which themes an element supports, controlling both light-dark() resolution and how browser-native UI (form controls, scrollbars) is styled to match.
Can you build a manual toggle alongside prefers-color-scheme?
Yes — default to color-scheme: light dark, then force a single value via a class or attribute your toggle sets. Every light-dark() call respects the override automatically.
Is light-dark() supported in all browsers in 2026?
Yes — Chrome, Safari, and Firefox all shipped it in 2024, making it Baseline widely available.
Should dark mode use pure black and pure white?
No — use a dark grey (roughly #121212–#1e1e1e) and an off-white instead, to avoid the eye-strain and halation effect of maximum contrast, especially on OLED screens.