CSS Theme Variables Without Duplication

It's surprisingly unwieldy to add a force dark/light mode button to a website. I thought there might be a way to influence prefers-color-scheme but there doesn't seem to be.

@mitsuhiko.at

I saw this post on Bluesky and thought I'd write up how I do light/dark mode on my website with CSS variables.

TLDR

<!-- Set the default theme to one of "light", "dark", "auto" -->
<html lang="en" data-theme="auto">
<head>
  <!-- Set the HTML theme preference -->
  <meta name="color-scheme" content="light dark">
</head>
</html>
:root {
  /* Color tokens */
  --white: rgb(251 249 255);
  --black: rgb(12   12  14);

  /* Match this to HTML theme preference */
  : light dark;
  --theme-light: initial;
  --theme-dark: ;
}

@media (prefers-color-scheme: dark) {
  :root {
    --theme-dark: initial;
    --theme-light: ;
  }
}

:root[data-theme="light"] {
  : light;
  --theme-light: initial;
  --theme-dark: ;
}

:root[data-theme="dark"] {
  : dark;
  --theme-dark: initial;
  --theme-light: ;
}

body {
  /* Color tokens used here! */
  : var(--theme-light, var(--white)) var(--theme-dark, var(--black));
  :            var(--theme-light, var(--black)) var(--theme-dark, var(--white));
}
// Load selected theme from localStorage.
// Inline in <head> to reduce FOUT.
(function () {
  const root = document.documentElement;

  if (typeof localStorage !== "undefined") {
    const theme = localStorage.getItem("theme");
    if (theme) {
      root.setAttribute("data-theme", theme);
    }
    else {
      // Set the default theme to one of "light", "dark", "auto"
      localStorage.setItem("theme", "auto");
      root.setAttribute("data-theme", "auto");
    }
  }
})();

How It Works

The first component to this madness is of course, CSS custom properties!

<style>
  #ex1 {
    --color: red;
  }

  #ex1 :nth-child(1) {
    background-color: var(--color);
  }
</style>

The second component is the fact that the initial value for CSS variables is a guaranteed-invalid value:

... using var() to substitute a custom property with this as its value makes the property referencing it invalid at computed-value time.

Guaranteed-Invalid Values

Therefore, setting a CSS variable to initial will result in an invalid value, causing the fallback argument to be substituted instead:

The second argument to the function, if provided, is a fallback value, which is used as the substitution value when the value of the referenced custom property is the guaranteed-invalid value.

Using Cascading Variables
<style>
  #ex2 {
    --color: initial;
    --red: red;
    --blue: blue;
  }

  #ex2 :nth-child(1) {
    background-color: var(--color, var(--red));
  }

  #ex2 :nth-child(2) {
    background-color: var(--color, var(--blue));
  }
</style>

The third component is the fact that you can set custom properties to the empty value, and that empty value is valid. The documentation for guaranteed-invalid values also makes sure to highlight this weird quirk:

This value serializes as the empty string, but actually writing an empty value into a custom property, like --foo: ;, is a valid (empty) value, not the guaranteed-invalid value. If, for whatever reason, one wants to manually reset a variable to the guaranteed-invalid value, using the keyword initial will do this.

Thus, we can set a CSS variable to empty to effectively hide the variable from substitution! Combining this component with the previous example:

<style>
  #ex3 {
    --red: red;
    --blue: blue;
  }

  #ex3 :nth-child(1) {
    /* Here, 'a' is set and 'b' is unset */
    --toggle-a: initial;
    --toggle-b: ;
    background-color: var(--toggle-a, var(--red)) var(--toggle-b, var(--blue));
  }

  #ex3 :nth-child(2) {
    /* Here, 'b' is set and 'a' is unset */
    --toggle-a: ;
    --toggle-b: initial;
    background-color: var(--toggle-a, var(--red)) var(--toggle-b, var(--blue));
  }
</style>

Putting It All Together

I hope you can now see how this is really useful for light/dark modes, because changing the theme is merely setting two CSS variables --theme-light and --theme-dark to initial and empty respectively!

By setting an HTML data attribute, we can support the following:

  • Automatic theme according to `prefers-color-scheme`

  • Explicit light mode

  • Explicit dark mode

:root {
  /* Match this to HTML theme preference */
  /* Implicit light mode */
  : light dark;
  --theme-light: initial;
  --theme-dark: ;
}

/* Implicit dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --theme-dark: initial;
    --theme-light: ;
  }
}

/* Explicit light mode */
:root[data-theme="light"] {
  : light;
  --theme-light: initial;
  --theme-dark: ;
}

/* Explicit dark mode */
:root[data-theme="dark"] {
  : dark;
  --theme-dark: initial;
  --theme-light: ;
}

The best part about this trick is that it works for any CSS value, so

: var(--theme-light, 2rem) var(--theme-dark, 8rem);
:  var(--theme-dark, 4rem);

works exactly how you would expect it to!

Codepen Example

Check out this minimal theme switcher and proof-of-concept on Codepen:

See the Pen No Duplication CSS Theming by kosayoda (@kosayoda) on CodePen.

Miscellaneous Thoughts

The problem of duplicating CSS variables for media queries and CSS selectors, since there is no way to combine the two:

:root {
  --light-color: red;
  --dark-color: blue;

  /* Default light theme */
  --bg-color: var(--light-color);
}

/* Implicit dark theme */
@media (prefers-color-scheme: dark) {
  /* When not explicit light theme */
  :root:not([data-theme="light"]) {
    --bg-color: var(--dark-color);
  }
}

/* Explicit dark theme */
:root[data-theme="dark"] {
  --bg-color: var(--dark-color);
}

/* Whatever setting gets picked, we'll use it here */
html {
  : var(--bg-color);
}

Note that above, the same configuration is duplicated across the implicit and explicit dark themes.

In most cases, you would only need to define semantic colors (eg. --text-color) once, which you can then use everywhere on the site. My main gripe is that sometimes I want to write custom styling for a post, which then requires the whole song and dance with media queries and attribute selectors at every definition site of the custom styles.

It's pretty cool! Unfortunately, it's not a general solution as it only works with colors.

The color-scheme CSS property allows an element to indicate which color schemes it can comfortably be rendered in.

(Edge 81, Firefox 96, Safari 13, Chrome 81, Opera 68)

Syntax: normal | [ light | dark | <custom-ident> ]+ && only?

MDN Reference

Sets the background color of an element.

(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 4, Opera 3.5)

Syntax: <color>

MDN Reference

Sets the color of an element’s text.

(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 3, Opera 3.5)

Syntax: <color>

MDN Reference

Shorthand property to set values for the thickness of the padding area. If left is omitted, it is the same as right. If bottom is omitted it is the same as top, if right is omitted it is the same as top. The value may not be negative.

(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 4, Opera 3.5)

Syntax: <‘padding-top’>{1,4}

MDN Reference

Shorthand property to set values for the thickness of the margin area. If left is omitted, it is the same as right. If bottom is omitted it is the same as top, if right is omitted it is the same as top. Negative values for margin properties are allowed, but there may be implementation-specific limits.

(Edge 12, Firefox 1, Safari 1, Chrome 1, IE 3, Opera 3.5)

Syntax: <‘margin-top’>{1,4}

MDN Reference