A few ways of specifying
per-theme colours
in only CSS

🗓️  (last updated )Tagged ,

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 going to assume the availability of a few CSS features:

  1. @media (prefers-color-scheme: dark) for automatic selection.
    It shipped in 2019–2020, which is generally enough.

  2. :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:

    1. Only support one behaviour (probably auto) sans-JS.
      This is a very reasonable choice.
      You might add a light or dark class to the root element.
    2. 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.
  3. 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.

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;
		}
	}
}

It’s often structured differently, with the nesting effectively inverted as in the next example,
but that’s the gist of it.

2. Lots of colour palette variables

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 most of the remaining approaches.

3. The space toggle hack

This is poor man’s 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.

It works because of a frankly ridiculous implementation detail in Custom Properties.
I’ll defer to Lea Verou for further explanation.

At the start of the stylesheet:

: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;
	}
}

And use site:

some-element {
	color: var(--light, darkred) var(--dark, pink);
}

And voilà: if light, darkred; if dark, pink.

It’s not a popular technique, and I forgot about it when compiling this list.
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

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

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

  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.
    At the time of writing, only Firefox is shipping this (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 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)
	);

5. 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);
}

6. if()

See also the space toggle hack, which is slightly less general than this,
but frankly similarly expressive for colour purposes, and less verbose.

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.

7. Paused @keyframes animation

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

@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
ManualForeverEverywhereUnwieldyNoAnyYou only have one theme.
Variables 2017-04At definition
site only
Barring the definition
repetition, great to use
NoAnyYou’re pretty normal,
but not fashionable.
Space toggle
hack
2017-04NoSlightly odd,
but very effective
NoAnyYou’re not afraid of
esoteric solutions.
color-mix()2023-05NoUnfamiliar, temporary
slight verbosity
Yes, but fiddly
if nonlinear
AnyYou’re probably having
too much fun.
light-dark()2024-05NoParagonNoOnly light/darkYou’re fashionable.
if()Only
Chromium
NoClear but verboseNoAnyYou don’t care about the
health of the web at all.
@keyframes2020-01It’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.

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