CSS light-dark() Function: Native Theme Switching
Dark mode implementation has historically required a non-trivial amount of boilerplate: a prefers-color-scheme media query that overrides every custom property, JavaScript to handle user toggles, localStorage to persist preferences, and an inline script in <head> to prevent the flash of the wrong theme on page load. The CSS light-dark() function does not eliminate all of this, but it dramatically reduces the CSS surface area of the problem.
light-dark() is a CSS color function that takes exactly two color values and returns the first when the active color scheme is light, or the second when the active color scheme is dark. It is the semantic CSS equivalent of the ternary operator for colors.
What Is light-dark()?
The function signature is simple:
color: light-dark(<light-color>, <dark-color>);
When the active color scheme is light, the browser uses <light-color>. When dark, it uses <dark-color>. The "active color scheme" is determined by the color-scheme CSS property, which in turn responds to the system prefers-color-scheme media query or an explicit value set on an element.
The function is supported in:
- Chrome/Edge: since version 123 (March 2024)
- Firefox: since version 120 (November 2023)
- Safari: since version 17.5 (June 2024)
Global support is around 85% as of early 2026. It is a relatively recent addition, but the browser coverage is growing fast enough to use in production with a fallback strategy.
How It Works with the color-scheme Property
light-dark() does not work in isolation. It depends entirely on the color-scheme CSS property being set correctly. Without it, the function has no context to decide which value to return.
The color-scheme property declares which color schemes a document or element supports. Setting it on :root is the starting point:
:root {
color-scheme: light dark;
}
This single declaration tells the browser that your page supports both light and dark color schemes. The browser then:
- Reads the user's
prefers-color-schemesystem preference - Applies the corresponding scheme
- Makes all
light-dark()calls on the page resolve to the appropriate value
With this in place, defining theme-aware colors becomes a matter of writing single declarations:
:root {
color-scheme: light dark;
--color-background: light-dark(#FFFFFF, #0F0F17);
--color-text: light-dark(#1A1A2E, #E8E8F0);
--color-border: light-dark(#DEE2E6, #2E2E4A);
--color-accent: light-dark(#2563EB, #60A5FA);
}
No media query. No selector overrides. One declaration per color, both values inline. The browser handles the switching automatically based on the system preference.
Restricting to a Single Scheme
Setting color-scheme: light or color-scheme: dark forces a single scheme regardless of system preference:
/* Always light, regardless of OS preference */
.widget {
color-scheme: light;
background: light-dark(#FFFFFF, #0F0F17);
/* Always resolves to #FFFFFF */
}
/* Always dark */
.dark-panel {
color-scheme: dark;
color: light-dark(#1A1A2E, #E8E8F0);
/* Always resolves to #E8E8F0 */
}
This is useful for UI components that should always appear in a specific mode — for example, a code editor that should always have a dark background regardless of the surrounding page theme.
The only Keyword
Adding only prevents the cascade from overriding the scheme for that element:
.forced-light {
color-scheme: only light;
}
This is primarily useful when you have an element inside a dark-mode context that must stay light.
Replacing prefers-color-scheme Media Queries
The traditional approach to dark mode using media queries requires duplicating or overriding every color variable:
/* Traditional approach — verbose */
:root {
--bg: #FFFFFF;
--text: #1A1A2E;
--accent: #2563EB;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0F0F17;
--text: #E8E8F0;
--accent: #60A5FA;
}
}
With light-dark(), this collapses to:
/* light-dark() approach — one declaration per variable */
:root {
color-scheme: light dark;
--bg: light-dark(#FFFFFF, #0F0F17);
--text: light-dark(#1A1A2E, #E8E8F0);
--accent: light-dark(#2563EB, #60A5FA);
}
The variables themselves become self-describing. When you read --accent: light-dark(#2563EB, #60A5FA), you immediately see both values and understand the relationship. The media query approach scatters light and dark values across two separate blocks, which makes auditing and updating the palette harder.
When Media Queries Are Still Needed
The prefers-color-scheme media query remains necessary for non-color adaptations that change based on theme:
@media (prefers-color-scheme: dark) {
/* Non-color adjustments that light-dark() cannot express */
img.logo {
filter: invert(1) brightness(1.2);
}
.hero-image {
opacity: 0.85;
}
}
For anything that is purely a color change, light-dark() is cleaner. For structural or non-color adaptations (image filters, opacity, display properties), the media query remains the right tool.
Combining with CSS Custom Properties
light-dark() works inside custom property values, which is where its full power emerges. You define all theme-aware colors at :root, and every component in the page references these variables. When the color scheme changes, everything updates simultaneously.
Full Theme System Example
:root {
color-scheme: light dark;
/* Backgrounds */
--color-bg-base: light-dark(#FFFFFF, #0F0F17);
--color-bg-elevated: light-dark(#F8F9FA, #1A1A2E);
--color-bg-overlay: light-dark(#F1F3F5, #252540);
/* Text */
--color-text-primary: light-dark(#1A1A2E, #E8E8F0);
--color-text-secondary: light-dark(#4A4A6A, #A8A8C0);
--color-text-muted: light-dark(#6C757D, #6A6A88);
--color-text-inverse: light-dark(#FFFFFF, #1A1A2E);
/* Borders */
--color-border: light-dark(#DEE2E6, #2E2E4A);
--color-border-strong: light-dark(#ADB5BD, #4A4A6A);
/* Interactive / brand */
--color-accent: light-dark(#2563EB, #60A5FA);
--color-accent-hover: light-dark(#1D4ED8, #93C5FD);
--color-accent-subtle: light-dark(#DBEAFE, #1E3A5F);
/* Feedback */
--color-success: light-dark(#16A34A, #4ADE80);
--color-warning: light-dark(#D97706, #FCD34D);
--color-danger: light-dark(#DC2626, #F87171);
--color-success-bg: light-dark(#F0FDF4, #052E16);
--color-warning-bg: light-dark(#FFFBEB, #2D1A00);
--color-danger-bg: light-dark(#FEF2F2, #2D0A0A);
}
Components reference these variables without needing to know anything about theming:
.card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.btn-primary {
background: var(--color-accent);
color: var(--color-text-inverse);
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
.alert-success {
background: var(--color-success-bg);
color: var(--color-success);
border-left: 3px solid var(--color-success);
}
Notice that the light accent #2563EB shifts to #60A5FA in dark mode. This is intentional — the 600-weight blue passes WCAG AA contrast against white but fails against dark backgrounds. The 400-weight lightens the color enough to maintain accessible contrast on dark surfaces. Use the Contrast Checker to verify each combination meets your target contrast ratio, and the Shade Generator to find the right shade for each mode.
Nesting light-dark() Inside Other Functions
light-dark() returns a color value, so it can be used anywhere a color is valid — including inside other functions:
:root {
color-scheme: light dark;
--brand: #3B82F6;
/* Use light-dark() inside color-mix() */
--brand-surface: color-mix(
in oklch,
var(--brand) 15%,
light-dark(white, #09090b)
);
}
This creates a surface color that is a 15% tint of the brand color, mixed with white in light mode and near-black in dark mode — automatically theme-aware.
Adding a User Toggle (JavaScript)
Responding to system preference is the right default, but users should be able to override it. This requires JavaScript to persist their choice and override the browser default.
Controlling color-scheme with JavaScript
The key insight is that color-scheme is a CSS property that can be set via JavaScript:
// Set the color scheme programmatically
document.documentElement.style.colorScheme = 'dark';
document.documentElement.style.colorScheme = 'light';
// Remove the override (reverts to system preference)
document.documentElement.style.colorScheme = '';
When you set color-scheme via inline style on the root element, it overrides the stylesheet declaration. All light-dark() values re-resolve to the appropriate variant.
Complete Toggle Implementation
const STORAGE_KEY = 'color-scheme-preference';
function initColorScheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') {
document.documentElement.style.colorScheme = stored;
}
// If no stored preference, the CSS color-scheme: light dark; handles it via OS preference
}
function toggleColorScheme() {
const current = getComputedStyle(document.documentElement)
.colorScheme
.trim();
// Determine next value
const next = current.includes('dark') ? 'light' : 'dark';
document.documentElement.style.colorScheme = next;
localStorage.setItem(STORAGE_KEY, next);
// Update any toggle button state
updateToggleButton(next);
}
function updateToggleButton(scheme) {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
btn.setAttribute('aria-label',
scheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
);
btn.dataset.scheme = scheme;
}
// Run before first paint to avoid flash
initColorScheme();
// Attach to toggle button after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('theme-toggle')
?.addEventListener('click', toggleColorScheme);
});
Calling initColorScheme() before the DOM is fully parsed is critical. If it runs late, users with a stored preference see the OS-default theme briefly before it switches — the classic "flash of wrong theme." Place this script inline in <head> or use the defer attribute carefully (note that defer scripts run after DOM parsing, which may be too late).
The Anti-Flash Pattern
The most robust anti-flash approach runs a minimal inline script in <head>:
<head>
<meta name="color-scheme" content="light dark">
<script>
const stored = localStorage.getItem('color-scheme-preference');
if (stored) {
document.documentElement.style.colorScheme = stored;
}
</script>
<link rel="stylesheet" href="styles.css">
</head>
This script runs synchronously before any CSS is applied, so the browser computes the correct color-scheme value from the first paint. The <meta name="color-scheme"> tag tells the browser which schemes to expect even before CSS parses — this prevents a brief white flash on dark-mode pages in some browsers.
Migration Guide from JS-Based Themes
Many existing dark-mode implementations use a data-theme attribute toggled by JavaScript, with CSS overrides scoped to [data-theme="dark"]. Migrating to light-dark() is incremental — you do not need to change everything at once.
Step 1: Add color-scheme to :root
:root {
color-scheme: light dark;
/* Existing custom properties remain unchanged */
}
Step 2: Migrate Variables One by One
Start with a single variable as a proof of concept. Replace the split declaration pattern with a unified light-dark():
/* Before */
:root {
--bg: #FFFFFF;
}
[data-theme="dark"] {
--bg: #0F0F17;
}
/* After */
:root {
color-scheme: light dark;
--bg: light-dark(#FFFFFF, #0F0F17);
}
Step 3: Update the Toggle
Change the JavaScript toggle from setting data-theme to setting style.colorScheme:
/* Before */
document.documentElement.setAttribute('data-theme', scheme);
/* After */
document.documentElement.style.colorScheme = scheme;
Step 4: Remove data-theme Selectors
Once all variables are migrated, remove the [data-theme="dark"] CSS blocks.
Keeping Both During Transition
You can run both systems simultaneously. Keep [data-theme="dark"] overrides for any variables not yet migrated. New variables use light-dark(). The JavaScript toggle sets both data-theme and style.colorScheme during the transition period.
Browser Support Fallback
For the approximately 15% of browsers without light-dark() support, provide explicit fallbacks:
:root {
/* Fallback: explicit light-mode values */
--color-bg: #FFFFFF;
--color-text: #1A1A2E;
/* Progressive enhancement with light-dark() */
--color-bg: light-dark(#FFFFFF, #0F0F17);
--color-text: light-dark(#1A1A2E, #E8E8F0);
}
/* Fallback dark mode for browsers without light-dark() */
@supports not (color: light-dark(white, black)) {
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0F0F17;
--color-text: #E8E8F0;
}
}
}
The @supports not (color: light-dark(white, black)) block only applies to browsers that do not understand light-dark(). Modern browsers skip it entirely because the negative condition is false.
Key Takeaways
light-dark(<light-value>, <dark-value>)returns the first argument in a light color scheme and the second in a dark scheme. It is the CSS-native way to express "this color, adapted for the current theme."- It depends on the
color-schemeCSS property being set on the element (or an ancestor). Always setcolor-scheme: light darkon:rootto enable automatic adaptation viaprefers-color-scheme. - The main advantage over the traditional media query approach is colocating both theme values in a single declaration — making the relationship between light and dark variants explicit and auditable.
- User overrides require setting
document.documentElement.style.colorSchemevia JavaScript. Persist the choice inlocalStorageand apply it in an inline<head>script before CSS loads to prevent flash. - Migration from
data-themeattribute-based systems is incremental — move one variable at a time from the[data-theme="dark"]override pattern to thelight-dark()inline pattern. - Browser support is ~85% as of 2026. Provide a
@supports notfallback with a@media (prefers-color-scheme: dark)block for older environments. - Use the Contrast Checker to verify that both the light and dark color values in each
light-dark()pair pass WCAG contrast requirements against their respective backgrounds, and the Shade Generator to find the right shade of each color for each mode.