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

Lobsters Hottest Tools

Summary

The article presents five CSS techniques for implementing per-theme colors (light/dark/auto) without JavaScript, using features like prefers-color-scheme, :has(), CSS variables, and color-mix().

<p><a href="https://lobste.rs/s/budvjr/few_ways_specifying_per_theme_colours">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 05/16/26, 11:11 AM

# A few ways of specifying per-theme colours in only CSS — Chris Morgan Source: [https://chrismorgan.info/css-themed-colours](https://chrismorgan.info/css-themed-colours) 🗓️2026\-05\-16•Tagged[/css](https://chrismorgan.info/css),[/meta=also](https://chrismorgan.info/meta%3Dalso) 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: 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 to2023\-12](https://caniuse.com/css-has); *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 \(2023\-12\); but they’re easily flattened if you wish\. 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; } } } ``` 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\.\) - Works since2017\-04\. - 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; --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\. ## 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 since2023\-05\. - 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*; you have to figure out how to express your desired function*mathematically*\. 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\.](https://chrismorgan.info/interpolating-themes) 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](https://drafts.csswg.org/css-color-5/#color-mix)allows and what[MDN talks of](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/color-mix): 1. The interpolation method is optional, defaulting to`oklab`, but this only happened in[late 2025](https://github.com/w3c/csswg-drafts/issues/10484#issuecomment-3201053810)\. 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\.](https://github.com/mdn/browser-compat-data/issues/28693) [Lightning CSS lacks it\.](https://github.com/parcel-bundler/lightningcss/issues/1224) 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\.](https://caniuse.com/mdn-css_types_color_color-mix_variadic_color_arguments) 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) ); ``` ## 4\.`light\-dark\(\)` - Works since2024\-05\(Safari 17\.5\)\. This just*barely*satisfies my preferred compatibility requirement, “two years of Safari and a year and a half of the rest”\. - Can’t use it for more or other themes than just light and dark\. - Syntax is compact and clear\. 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); } ``` ## 5\.`if\(\)` - Only implemented by Chromium family \(2025\-05\); 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, - … but is fairly 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\. ## 5\.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\. - People will think you crazy\. There’s an above\-average chance they may be right\. - Works since2020\-01\(when Edge adopted Chromium; other than that it’d be2013\-07\)\. - As with`color\-mix\(\)`, the technique*begs*you to allow mixing colours/themes\! - If you really want to blend themes, you won’t get better than this for controlling interpolation\. \(It’s almost like it was designed for it\!\) \(Though… keyframes are not*always*better than mathematical functions\.\) - 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\. Finally as summary, here’s a comparison table\. TechniqueSupportedSelector repetitionPretty/ maintainable?InterpolationThemesWhat using it suggestsManualForeverEverywhereUnwieldyNoAnyYou only*have*one theme\.Variables†2017\-04At definition site onlyBarring the definition repetition, great to useNoAnyYou’re pretty normal, but not*fashionable*\.`color\-mix\(\)`2023\-05NoUnfamiliar, temporary slight verbosityYes, but fiddly if nonlinearAnyYou’re probably having too much fun\.`light\-dark\(\)`2024\-05NoParagonNoOnly light/darkYou’re fashionable\.`if\(\)`Only ChromiumNoClear but verboseNoAnyYou don’t care about the health of the web*at all*\.`@keyframes`2020\-01It’s so much worse than just thatThis cell is too small to adequately describe the nightmareNear\-ultimate flexibilityIn 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\.

Similar Articles

Algorithmic Theming Engines

Hacker News Top

The article introduces the new CSS `contrast-color()` function, which allows developers to automatically choose black or white text for accessible contrast against any background color, addressing the persistent issue of low contrast on the web without relying on JavaScript.

Six Levels of Dark Mode (2024)

Hacker News Top

An article exploring six levels of dark mode implementation in web design, ranging from barebone meta tag approaches to advanced color scheme switching techniques using CSS.

Dark mode with web standards

Lobsters Hottest

A guide on implementing dark mode on websites using web standards like the color-scheme meta tag and prefers-color-scheme media query, with a JavaScript toggle that respects user preferences and stores them in localStorage.

Generative colors with CSS

Lobsters Hottest

A tutorial on using the CSS oklch() function and relative colors to dynamically generate full color palettes from a single hex code, simplifying color management on websites.