Over the years, web developers have adopted the prefers-color-scheme
media query to let websites adapt to a user’s OS dark/light preference. The typical pattern is:
:root {
--color-text: #333;
--color-bg: #fff;
}
@media (prefers-color-scheme: dark) {
:root {
--color-text: #ccc;
--color-bg: #121212;
}
}
body {
color: var(--color-text);
background-color: var(--color-bg);
}
This works well for colors; but what if you want a property that’s not a color—say a background-image
, border-style
, font-size
, or even layout tweaks—to change based on theme? You end up writing duplicate rules, or embedding theme-based logic in JavaScript. That becomes tedious and error prone.
In late 2023, Bramus demonstrated a custom CSS --light-dark()
function (using new CSS features in development) that can generalize the behavior of the native light-dark()
(which only handles colors). His approach uses @function
, inline if()
, style()
queries, @scope
, and attr()
to build a theming primitive that can return any CSS value based on a theme preference (light vs dark).
This tutorial unpacks that idea, shows how it works, and there’s also a CodePen you can try yourself.
Native light-dark()
today: power—but color-only
Before digging into the custom version, let’s see what the native light-dark()
offers today. It’s part of the CSS Color Module Level 5 spec, and allows you to write:
:root {
color-scheme: light dark;
/* define theme-aware custom properties */
--text-color: light-dark(#333, #efefef);
--bg-color: light-dark(ghostwhite, #222);
}
body {
color: var(--text-color);
background-color: var(--bg-color);
}
Here, light-dark(a, b)
returns a
if the used color scheme is light
(or unknown) and b
if the used scheme is dark
. Because color-scheme: light dark
is declared on :root
, the browser negotiates which scheme to use based on the user’s OS setting (or preferences). You can also force a subtree into one scheme by overriding color-scheme
on a child element.
The limitation: it only works with <color>
values, i.e. for properties that accept colors. You cannot pass url(...)
, px
, dashed
, or font weights as arguments to the native light-dark()
.
Custom --light-dark()
using @function
, if()
, and @scope
Here is a high-level idea:
- You maintain a custom property
--scheme
on each element, which can be"light"
,"dark"
, or"system"
. - On
:root
, you set--scheme
based onprefers-color-scheme
. Also store it in--root-scheme
so that"system"
logic can refer to it. - You write a CSS
@function --light-dark(--light, --dark)
that internally queries the current element’s--scheme
and picks the right one viaif(style(--scheme: dark))
. - You wrap parts of the DOM in
[data-scheme]
elements (or use@scope ([data-scheme])
) so you can override the--scheme
downward and let nativelight-dark()
continue to function for color values too. - Then in your theme-aware rules you write things like:
[data-scheme] { color: light-dark(#333, #e4e4e4); /* native for color */ background-color: light-dark(aliceblue, #333); border: 4px --light-dark(dashed, dotted) currentcolor; font-weight: --light-dark(500, 300); font-size: --light-dark(16px, 18px); }
Because --light-dark()
returns any CSS value (not just a color), you can generalize theme-based variation across many CSS properties.
Here’s the skeleton of the approach (adapted and simplified):
:root {
--root-scheme: light;
--scheme: light;
@media (prefers-color-scheme: dark) {
--root-scheme: dark;
--scheme: dark;
}
}
/* Custom function: returns --dark if scheme is dark, else --light */
@function --light-dark(--light, --dark) {
result: if(
style(--scheme: dark): var(--dark);
else: var(--light)
);
}
/* Enable overriding by data-scheme attribute */
@scope ([data-scheme]) {
:scope {
--scheme-from-attr: attr(data-scheme type(<custom-ident>));
--scheme: if(
style(--scheme-from-attr: system): var(--root-scheme);
else: var(--scheme-from-attr)
);
color-scheme: var(--scheme);
}
}
/* Usage block */
[data-scheme] {
color: light-dark(#333, #e4e4e4);
background-color: light-dark(aliceblue, #333);
border: 4px --light-dark(dashed, dotted) currentcolor;
font-weight: --light-dark(500, 300);
font-size: --light-dark(16px, 18px);
}
A few notes:
- The
if(style(--scheme: dark))
style query picks up the--scheme
value on that element. - The
@scope ([data-scheme])
block allows elements carryingdata-scheme="light"
,"dark"
, or"system"
to override the scheme locally. Ifsystem
is chosen, it falls back to--root-scheme
. - You set
color-scheme: var(--scheme)
on the element so nativelight-dark()
still works for color properties within that subtree. (The browser’s internal handling of system colors benefits from that.) - When using nested theming, wrapping
[data-scheme]
can help. - Later on, when inline
if()
inside custom functions got support (in prototype builds), he refined the function to avoid requiring container wrappers altogether.
In effect, this gives you a flexible theming primitive that behaves like a mix of native light-dark()
and a general-purpose schemed-value()
.
A live demo
Keep in mind: this only works in browsers with experimental CSS @function
/ if()
/ style-query support enabled (e.g. Chrome Canary with “Experimental Web Platform Features”).
See the Pen –light-dark() Function in CSS by Alex Ivanovs (@stackdiary) on CodePen.
When you open this in a compatible browser (Canary + flags), you should see three boxes styled differently: one always “light,” one always “dark,” and one following your OS setting.
You can of course extend this approach to background images, layout tweaks, SVG variants, gradient directions, and more.
Caveats, limitations, and browser support
There’s a good chance that some parts of the approach used in this tutorial are eventually going to break because many of the features are still in either early development or in active development and subject to change.
- The custom
--light-dark()
as described is experimental. It relies on CSS features currently in development (custom functions, inlineif()
, style queries,@scope
) that aren’t broadly supported yet. - Native
light-dark()
is supported in modern browsers (Chrome 123+, Firefox 120) but only for<color>
values. - The custom version cannot yet be used reliably in production; treat it as a prototype or “future sketch.”
- Because
attr()
for types beyond content (e.g.type(<custom-ident>)
) is still under experimental status, some parts of this logic may break or change. - The approach assumes that you’ll wrap portions of the tree in
[data-scheme]
or otherwise manage--scheme
values. For large codebases, architecting how and where to apply overrides needs planning. - Always watch for fallback behavior in unsupported browsers. A safe strategy is to degrade gracefully (i.e. without custom theming) or provide polyfills via JS/CSS.
This custom --light-dark()
offers a glimpse of how CSS theming might evolve: turning theme-based logic into first-class, declarative, CSS-native constructs. It blends the simplicity of light-dark()
with the flexibility of arbitrary value switching—no JavaScript needed (in future).
Until those features stabilize, you can keep using native light-dark()
for color logic, and layer in JavaScript or PostCSS fallbacks for non-color theming. But it’s worth watching this space: once custom functions, if()
, and style queries mature, your theme system may become far more expressive and compact.