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)

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.

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.

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.)

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:

  1. 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.

  2. 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()

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()

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.

@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.

Body text: Fonts:
Theme:
Explanation of all this
(yes, this works without JavaScript; persists to cookies)