#Frontend

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

Tech Essays Reporter
4 min read

The article surveys six pure‑CSS strategies for offering automatic, light, and dark themes selectable via radio buttons, without any JavaScript. It compares classic variable‑based palettes, the newer color‑mix() and light‑dark() functions, the experimental if() conditional, and a quirky paused‑animation technique, evaluating compatibility, maintainability, and extensibility.

Thesis

When a site needs to let users pick auto, light or dark colour schemes without any JavaScript, CSS alone can provide a surprisingly rich toolbox. By combining the prefers‑color‑scheme media query with the modern :has() relational selector, we can react to the state of hidden radio inputs and switch the visual palette entirely in the stylesheet. The article walks through six concrete patterns, weighing their browser support, syntactic overhead, and how far they can be extended beyond a simple light/dark dichotomy.

Key arguments

1. The "hard‑way" explicit overrides

  • How it works – Write each rule twice: once for the default (light) and once inside a :root:has(#theme-dark:checked) block, with an additional @media (prefers-color-scheme: dark) clause for the auto case.
  • Pros – Works everywhere, no variables required, easy to see the exact colour for each state.
  • Cons – Verbose, repeats colour values, becomes unwieldy as the number of themed properties grows.

2. Palette variables

  • How it works – Define custom properties (e.g. --color-primary) on :root for the light theme, then override them inside :root:has(#theme-dark:checked) and the auto media query.
  • Pros – Centralises colour definitions, makes adding new themes trivial, supported since early 2017.
  • Cons – Still repeats the full set of variables for each theme, and the separation between definition and use can obscure the visual intent.

3. color-mix() with a single numeric variable per theme

  • How it works – Declare a percentage variable (e.g. --dark) that is 0% for light and 100% for dark. Each colour is expressed as color-mix(in oklab, light‑color, dark‑color var(--dark)).
  • Pros – Very compact per‑property syntax, opens the door to gradual blending between themes, and scales to more than two themes by adding further variables.
  • Cons – Requires the color-mix() function (supported in Firefox as of 2026, Chrome/Edge later) and explicit interpolation syntax (in oklab). Mixing more than two colours is still experimental in many browsers.

4. light‑dark()

  • How it works – Set color-scheme on :root and then use light-dark(light‑value, dark‑value) wherever a colour is needed.
  • Pros – The most readable expression for a binary theme, minimal boilerplate, works as soon as the browser implements the function (Safari 17.5+, Chrome/Edge 2024+).
  • Cons – Limited to exactly two themes; cannot express intermediate or custom palettes.

5. if() conditional function (Chromium‑only)

  • How it works – Store the current theme name in a custom property (--theme: light|dark). Then write if(style(--theme: light): light‑color; style(--theme: dark): dark‑color;).
  • Pros – Works with any CSS value, not just colours, and adds new themes by extending the if list.
  • Cons – Only available in Chromium‑derived browsers (Chrome, Edge) as of mid‑2025, making it unsuitable for cross‑browser projects.

6. Paused @keyframes animation trick

  • How it works – Define a keyframe that animates from the light colour to the dark colour. Apply the animation in a paused state for the light theme; when dark is selected, give the animation a negative delay so the computed style snaps to the “to” keyframe.
  • Pros – Demonstrates the flexibility of CSS animations for theme switching and can be extended to non‑colour properties.
  • Cons – Extremely verbose, fragile with specificity, and generally confusing to maintain; best left as a curiosity.

Implications

  • Compatibility first – For production sites that must run on all major browsers, the variable‑based palette (Technique 2) or the explicit hard‑way (Technique 1) remain the safest bets.
  • Future‑proofing – As color-mix() and if() gain broader support, they enable smoother transitions and richer theming (e.g., user‑controlled blends, multi‑theme palettes like grass and ocean).
  • Maintainability – Centralising colours in custom properties reduces duplication, but developers should avoid over‑abstracting to the point where the visual intent is lost. A clear naming convention for variables (e.g., --color‑text‑primary) helps.
  • Performance – All techniques are pure CSS; the browser computes the final colour at style‑resolution time. The only measurable cost is the extra CSS size when many variables or keyframes are declared.

Counter‑perspectives

  • Some argue that relying on :has() ties the theme system to a selector that, while widely supported now, is still relatively new. A fallback using sibling combinators (#theme‑dark:checked ~ *) can mitigate this, though it introduces layout constraints.
  • The light‑dark() function, while elegant, locks the design into a binary model. Projects that anticipate future brand extensions may find the function too limiting and prefer the variable or color-mix() approaches.
  • The if() function’s exclusivity to Chromium means a mixed‑browser audience would experience a broken theme switch unless a polyfill or progressive‑enhancement strategy is added, re‑introducing JavaScript—the very thing the article tries to avoid.

Takeaway: Pure‑CSS theming is no longer a theoretical curiosity; with a modest set of selectors and functions, developers can offer automatic, light, and dark modes without a single line of script. Choosing the right technique hinges on the target browser matrix, the desire for extensibility beyond two themes, and the team’s tolerance for CSS verbosity. As browser support for color-mix() and if() matures, the balance will likely shift toward those more expressive, mathematically‑driven methods.

Comments

Loading comments...