You’re reading from my feeds; you will accordingly probably miss out on a few styles and niceties.
In this article’s case: ✔/✓/±/?¿/✗/✘ list markers, and pretty colours on the summary table.
View it on my website for the Authentic Intended Experience. Or just take it as it is.
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)
- Must support auto (based on
prefers-color-scheme),
light, and dark, chosen by radio buttons. - Must work without any JavaScript (persistence is out of scope).
The assumed basic HTML and CSS
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:- Only support one behaviour (probably auto) sans-JS.
This is a very reasonable choice.
You might add alightordarkclass to the root element. - Shift the radio buttons to be direct children of the body,
hiding them visually,
and then target#theme-foo:checked ~ * …
instead of:root:has(#theme-foo:checked) ….
Messy and with some consequences and inconveniences, but generally possible.
- Only support one behaviour (probably auto) sans-JS.
I will also use nested selectors in some places in this article ();
but they’re easily flattened if you wish.
Now to the seven techniques.
Old-school and verbose.
It’s often structured differently, with the nesting effectively inverted as in the next example,
Probably the most popular approach, traditionally.
Though I was surprised to realise the implementations only landed in 2014–2017.
The declaration:
Adding more themes is trivial.
And use site:
You can also obviously use variables for most of the remaining approaches.
This is poor man’s It works because of a frankly ridiculous implementation detail in Custom Properties.
At the start of the stylesheet:
And use site:
And voilà: if light, It’s not a popular technique, and I forgot about it when compiling this list.
I don’t recall encountering this technique before
Basically, define a colour as a mixture of all the colours across all themes,
This is the technique I settled on for my own site,
At the start of the stylesheet:
Then with each colour we can use And it will be Mostly you only care about changing colours,
Beware of following what the spec allows and what MDN talks of:
The interpolation method is optional, defaulting to The spec permits one or more colour specification,
You can extend this to more than two themes by setting more variables and mixing more colours.
Hopefully in 2028 (or once Lightning CSS or similar supports it),
To begin with, you must set Then:
See also the space toggle hack , which is slightly less general than this,
At the start of the stylesheet:
Then with each colour use:
This is differently flexible than Adding more themes is trivial based on what has already been shown.
This is a tricky technique I’ve seen applied occasionally,
Instead of probably defining colour palette variables,
I’m not going to explain this one further;
1. Write it all out the hard way
some-element {
color: darkred;
:root:has(#theme-dark:checked) & {
color: pink;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) & {
color: pink;
}
}
}
but that’s the gist of it.
2. Lots of colour palette variables
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.)
and may or may not fit nicely into your project.
It can encourage some bad and some good patterns, and may yield a little more overhead.
(I’m being vague deliberately, and will not elaborate here.)
: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: …;
⋮
}
}some-element {
color: var(--color-somepurpose);
}3. The space toggle hack
(though most tools will be canny to it by now).
if() (which actual function we’ll get to ).
You get a single-branch conditional spelled var(--condition, value).
Honestly it’s not really worse than if() for cases like this, just different.
I’ll defer to Lea Verou for further explanation.
:root {
--light: initial;
--dark: ;
}
:root:has(#theme-dark:checked) {
--light: ;
--dark: initial;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--light: ;
--dark: initial;
}
}some-element {
color: var(--light, darkred) var(--dark, pink);
}darkred; if dark, pink.
But someone reminded me after publication, so I added it:
it’s worth considering, especially if you want longer compatibility,
because it and variants really do work rather well.
4.
color-mix() with one variable per theme
(actually I’ve been surprised at how little attention color-mix() has been paid),
but it works.
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.)
Pity about the interpolation method part that’s currently necessary.
What does --dark: 50%; mean?
You can play around with the consequences !
you have to figure out how to express your desired function mathematically.
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.
:root {
--dark: 0%;
}
:root:has(#theme-dark:checked) {
--dark: 100%;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--dark: 100%;
}
}color-mix() like so:
some-element {
color: color-mix(in oklab, darkred, pink var(--dark));
}darkred in light mode and pink in dark mode.
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.)
Syntax compatibility hazards
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.
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).
More than two themes
Defining a couple of new themes named Grass and Ocean:
And using them:
:root {
--grass: 0%;
--ocean: 0%;
}
:root:has(#theme-grass:checked) {
--grass: 100%;
}
:root:has(#theme-ocean:checked) {
--ocean: 100%;
}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)
);
}
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)
);5.
light-dark()
This just barely satisfies my preferred compatibility requirement,
“two years of Safari and a year and a half of the rest”.
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;
}some-element {
color: light-dark(darkred, pink);
}6.
if()
but frankly similarly expressive for colour purposes, and less verbose.
:root {
--theme: light;
}
:root:has(#theme-dark:checked) {
--theme: dark;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--theme: dark;
}
}some-element {
color: if(
style(--theme: light): darkred;
style(--theme: dark): pink;
);
}color-mix():
color-mix() is limited to colours only, and allows continuous interpolation;
if() works on all types, but is limited to discrete values.
7. Paused
@keyframes animation
but I don’t remember ever seeing it used for colours.
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!
(It’s almost like it was designed for it!)
(Though… keyframes are not always better than mathematical functions.)
and some really painful composition of animation declarations.
because animations are even more powerful than !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;
}
}
}
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. |
| Space toggle hack | 2017-04 | No | Slightly odd, but very effective | No | Any | You’re not afraid of esoteric solutions. |
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.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.