A few ways of specifying per-theme colours in only CSS
Draft: started , last updated • 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)
- 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, it can be done without JavaScript if desired.)
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 also going to assume the availability of @media (prefers-color-scheme: dark) for automatic selection, and :has() for manual selection without needing additional JavaScript.
:has() is only supported back to , which I consider to be (just barely) enough to depend on totally for most purposes.
If you’re not happy with that but still want it all to work without JS, you’ll need to 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. Or you can just have only one or only auto theme work sans-JS. That may be fair enough.
I will also use nested selectors in some places in this article, which are easily flattened if you care.
Now to the five techniques. 1. Write it all out the hard way
Old-school and verbose.
- The most compatible.
- It’s easy to see how to add more themes than just light and dark.
- Syntax is verbose.
- Dark theme value has to be repeated.
some-element {
color: darkred;
:root:has(#theme-dark:checked) & {
color: pink;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) & {
color: pink;
}
}
}
2. Lots of colour palette variables
Probably the most popular approach, traditionally.
- Works since .
- It’s easy to see how to add more themes than just light and dark.
- Use-site syntax is ideal.
- Separating colour definitions from their use location may help or hinder, 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.)
- Dark theme value has to be repeated.
The declaration:
:root {
--color-somepurpose: darkred;
}
:root:has(#theme-dark:checked) {
--color-somepurpose: pink;
}
@media (prefers-color-scheme: dark) {
:root:has(#theme-auto:checked) {
--color-somepurpose: pink;
}
}
Adding more themes is trivial.
And use site:
some-element {
color: var(--color-somepurpose);
}
3. color-mix() with one variable per theme
I 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.)
- Works since .
- It’s easy to see how to add more themes than just light and dark.
- Syntax is reasonably compact.
- Syntax is likely to be unfamiliar, but is fairly straightforward.
Pity about the interpolation method part that’s currently necessary. - Opens up interesting ideas for actually mixing colours/themes. What does
--dark: 50%; mean?
You can play around with the consequences ! - If you do try mixing colours and themes, controlling interpolation is a pain.
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 written 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.) Syntax compatibility hazards
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 couple of years.
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.
Firefox is first to ship this, today.150, 2026-04-21
More than two themes
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 srgb, color-mix(in srgb, color-mix(in srgb,
darkred,
pink var(--dark)),
green var(--grass)),
blue var(--ocean)
);
}
Hopefully in 2028 (or once a compatibility-transpilation tool you use like Lightning CSS 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 srgb,
darkred,
pink var(--dark),
green var(--grass),
blue var(--ocean)
);
4. light-dark()
- Works since (Safari 17.5). A little too short for my liking on most sites, I prefer two years of Safari and a year and a half of the rest.
- Can’t use it for more themes than just light and dark.
- Syntax is compact and clear.
some-element {
color: light-dark(darkred, pink);
}
Make sure you’ve set color-scheme properly. You should be doing this with any of the techniques for the sake of scroll bars and form elements, 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;
}
5. if()
- Only implemented by Chromium family (); not yet supported on Firefox or Safari.
- It’s easy to see how to add more themes than just light and dark.
- Syntax largely matches programmer expectations.
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.
5. @keyframes (paused animation)
This is a tricky technique I’ve seen applied occasionally, but I don’t remember ever seeing it used for colours.
Basically, define animation keyframes for each way you use a colour, where “from” means light mode and “to” means dark mode, and set a paused animation, with a negative delay in dark mode so that the animation is at the “to” value.
- People will think you crazy. There’s an above-average chance they may be right.
- Works since (when Edge adopted Chromium; other than that it’d be ).
- As with
color-mix(), the technique begs you to allow mixing colours/themes! - If you really want to mix colours and themes, you won’t get better than this for controlling interpolation.
(It’s almost like it was designed for it!) - Unless you have no other animations, you’ll be stuck with a lot of repetition, and some really painful composition of
animation declarations. - I bet you’ll run into specificity-style troubles somewhere along the way, 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;
}
}
}
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.