🗓️ • Tagged /css, /meta=also
I was thinking about this as part of putting this website together.
Actually it was because I forgot about light-dark();
if I’d remembered that earlier I probably wouldn’t have ended up with all this!
My requirements: (which may not match your requirements)
prefers-color-scheme),Up to you exactly how to include and structure it, but all the examples that follow assume something like this:
<fieldset>
<legend>Theme</legend>
<label><input type=radio name=theme id=theme-auto checked> Follow system</label>
<label><input type=radio name=theme id=theme-light> Light</label>
<label><input type=radio name=theme id=theme-dark> Dark</label>
</fieldset> I’m going to assume the availability of a few CSS features:
@media (prefers-color-scheme: dark) for automatic selection.
It shipped in 2019–2020, which is generally enough.
:has() for manual selection without needing additional JavaScript.
It’s newer, supported back to ;
I consider that enough to rely on in general,
but if you’re not happy with it, you have two options:
light or dark class to the root element. #theme-foo:checked ~ * … :root:has(#theme-foo:checked) …. I will also use nested selectors in some places in this article ();
but they’re easily flattened if you wish.
Now to the five techniques.
Old-school and verbose.
some-element {
color: darkred;
:root:has(#theme-dark:checked) & {
color: pink;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) & {
color: pink;
}
}
} It’s often structured differently, with the nesting effectively inverted as in the next example,
but that’s the gist of it.
Probably the most popular approach, traditionally.
Though I was surprised to realise the implementations only landed in 2014–2017.
Sure feels longer ago than that. But it still feels okay to call it “traditional”,
because people often used Sass variables to similar effect before.
(You didn’t often have multiple themes in those days.)
The declaration:
:root {
--color-somepurpose: darkred;
--color-another: …;
â‹®
}
:root:has(#theme-dark:checked) {
--color-somepurpose: pink;
--color-another: …;
â‹®
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--color-somepurpose: pink;
--color-another: …;
â‹®
}
} Adding more themes is trivial.
And use site:
some-element {
color: var(--color-somepurpose);
} You can also obviously use variables for the next three approaches.
color-mix() with one variable per themeI don’t recall encountering this technique before
(actually I’ve been surprised at how little attention color-mix() has been paid),
but it works.
Basically, define a colour as a mixture of all the colours across all themes,
but use a variable to set one of the themes to 100% and the rest to 0%.
(In practice, you’re normally just using two themes: light and dark.)
--dark: 50%; mean? This is the technique I settled on for my own site,
because once I realised the potential,
I wanted to play around with mixing themes.
I’ve started writing more about it.
It’s more interesting/involved than you might imagine.
At the start of the stylesheet:
:root {
--dark: 0%;
}
:root:has(#theme-dark:checked) {
--dark: 100%;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--dark: 100%;
}
} Then with each colour we can use color-mix() like so:
some-element {
color: color-mix(in oklab, darkred, pink var(--dark));
} And it will be darkred in light mode and pink in dark mode.
Mostly you only care about changing colours,
but if you wanted to change other things based on the theme you could,
by making the variable a number instead of a percentage, and using calc().
(Without having thought through the implications—
I wish percentages and numbers were unified, with 100% equivalent to 1.)
Beware of following what the spec allows and what MDN talks of:
The interpolation method is optional, defaulting to oklab, but this only happened in late 2025.
All major browsers have now shipped it,
but by my definition it’s not going to be safe to rely on for another year and a half or so.
So for quite some time yet, you’ll still need to write in oklab,.
MDN browser-compat-data lacks it.
Lightning CSS lacks it.
The spec permits one or more colour specification,
which is handy for mixing more than two themes,
but most implementations still only support exactly two.
This one is tracked in MDN browser-compat-data.
Lightning CSS lacks it.
At the time of writing, only Firefox is shipping this (150, 2026-04-21).
You can extend this to more than two themes by setting more variables and mixing more colours.
Defining a couple of new themes named Grass and Ocean:
:root {
--grass: 0%;
--ocean: 0%;
}
:root:has(#theme-grass:checked) {
--grass: 100%;
}
:root:has(#theme-ocean:checked) {
--ocean: 100%;
} And using them: some-element {
color: color-mix(in oklab, color-mix(in oklab, color-mix(in oklab,
darkred,
pink var(--dark)),
green var(--grass)),
blue var(--ocean)
);
} Hopefully in 2028 (or once Lightning CSS or similar supports it),
you’ll be able to shorten this to the following
(which will also work better with values between 0% and 100%):
color: color-mix(in oklab,
darkred,
pink var(--dark),
green var(--grass),
blue var(--ocean)
); light-dark()To begin with, you must set color-scheme properly.
You should do this with all of the techniques,
for a few reasons such as scroll bar and form element colouring,
but with light-dark() it’s necessary for it to work at all.
:root {
color-scheme: light dark;
}
:root:has(#theme-light:checked) {
color-scheme: light;
}
:root:has(#theme-dark:checked) {
color-scheme: dark;
} Then:
some-element {
color: light-dark(darkred, pink);
} if()At the start of the stylesheet:
:root {
--theme: light;
}
:root:has(#theme-dark:checked) {
--theme: dark;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--theme: dark;
}
} Then with each colour use:
some-element {
color: if(
style(--theme: light): darkred;
style(--theme: dark): pink;
);
} This is differently flexible than color-mix(): color-mix() is limited to colours only, and allows continuous interpolation; if() works on all types, but is limited to discrete values.
Adding more themes is trivial based on what has already been shown.
@keyframes animationThis is a tricky technique I’ve seen applied occasionally,
but I don’t remember ever seeing it used for colours.
Instead of probably defining colour palette variables,
define colour palette keyframes rules (one per property you use it with!),
where “from” means light mode and “to” means dark mode.
Then at use sites, add a paused animation,
and in dark mode apply a negative delay to put the animation at the “to” (dark) value.
color-mix(), the technique begs you to allow mixing colours/themes! animation declarations. !important . @keyframes color-somepurpose {
from { color: darkred; }
to { color: pink; }
}
some-element {
animation: 1s paused color-somepurpose forwards;
:root:has(#theme-dark:checked) & {
animation-delay: -1s;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) & {
animation-delay: -1s;
}
}
} I’m not going to explain this one further;
if you don’t understand it, it’s probably for the best—
or else take it as a challenge.
Finally as summary, here’s a comparison table.
| Technique | Supported | Selector repetition | Pretty/ maintainable? | Interpolation | Themes | What using it suggests |
|---|---|---|---|---|---|---|
| Manual | Forever | Everywhere | Unwieldy | No | Any | You only have one theme. |
| Variables †| 2017-04 | At definition site only | Barring the definition repetition, great to use | No | Any | You’re pretty normal, but not fashionable. |
color-mix() | 2023-05 | No | Unfamiliar, temporary slight verbosity | Yes, but fiddly if nonlinear | Any | You’re probably having too much fun. |
light-dark() | 2024-05 | No | Paragon | No | Only light/dark | You’re fashionable. |
if() | Only Chromium | No | Clear but verbose | No | Any | You don’t care about the health of the web at all. |
@keyframes | 2020-01 | It’s so much worse than just that | This cell is too small to adequately describe the nightmare | Near-ultimate flexibility | In theory any (but please don’t try) | Disturbed genius? That’s about my limit for benevolent interpretation. |
†This is variables applied to manual, but you can definitely use it with color-mix(), light-dark() and if(), and will probably do so. @keyframes kinda already includes its own alternative, and it’s probably worse than it sounds.