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

    html
<!-- 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>
    css
:root {
  /* Color tokens */
  --white: rgb(251 249 255);
  --black: rgb(12   12  14);

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

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

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

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

body {
  /* Color tokens used here! */
  background-color: var(--theme-light, var(--white)) var(--theme-dark, var(--black));
  color:            var(--theme-light, var(--black)) var(--theme-dark, var(--white));
}
    js
// 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!

    Example
<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
    Example
<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:

    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 */
  color-scheme: 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"] {
  color-scheme: light;
  --theme-light: initial;
  --theme-dark: ;
}

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

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

padding: var(--theme-light, 2rem) var(--theme-dark, 8rem);
margin:  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 {
  background-color: 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.