Theming with CSS Custom Properties

CSS custom property fundamentals, the theming use case, and some patterns for global/component theming.

CSS custom properties, also called CSS variables, are containers for specific values that can be reused throughout a document. In a very basic example, they look like this:

:root {
  --clr-primary: blue;
}

h1 {
  color: var(--clr-primary);
}

CSS custom properties have been supported by modern browsers for a while now and are changing how we write/structure CSS, as well as how we use JavaScript to interact with UI components. According to the State of CSS 2021, 85% of developers use CSS custom properties. They provide some unique benefits, including:

They also share some of the same benefits as preprocessor variables:

I personally use CSS custom properties in all my projects. Their syntax, which starts with --, lets processors ignore them during compilation, and allows them to go directly to the outputted CSS. This means you can use them in any stylesheet, whether it’s preprocessor or plain CSS. You can also use them in inline styles.

In this note, I’d like to go over the fundamentals of CSS custom properties, then dive into a use case that they’re particularly helpful for: theming. I’ll also share a pattern for updating component styles globally. Finally, we’ll discuss some of its pitfalls.

Basic rules of CSS custom properties

Declaration

To declare a custom property, instead of using a usual CSS property like color or margin, you decide a name for the property and use the -- prefix, followed by a valid CSS value. A couple things to note:

/* invalid - must be inside a selector */
--clr-primary: blue;

:root {
  /* valid but are different - custom properties are case sensitive */
  --clr-primary: blue;
  --CLR-PRIMARY: blue;
}

The value of the property can be any valid CSS value, including colors, lengths, and other custom properties:

:root {
  --transition-duration: 300ms;
  --white: rgb(240, 240, 240);

  --clr-bg: var(--white);
}

Usage with fallback values

We can use custom properties by specifying their name inside the var() function. This function takes two parameters, the first being the name of the custom property, and the second a fallback value. Anything between the first comma and the end of the function is treated as the fallback value. Here’s some patterns:

.btn {
  /* use without fallback */
  margin: calc(var(--spacing) * 1px);

  /* blue if --clr-primary is not defined */
  background-color: var(--clr-primary, blue);

  /* the entire string `'Helvetica', sans-serif` is the fallback value */
  font-family: var(--ff-primary, 'Helvetica', sans-serif);

  /* correct way to provide more than one fallback - blue if --clr-primary and --clr-secondary are not defined */
  color: var(--clr-primary, var(--clr-secondary, blue));
}

Cascade and inheritance

CSS custom properties conform to the cascade. This means values declared in a selector with a higher specificity will override those with a lower specificity, and values declared later in the stylesheet will override those declared earlier. This allows the same custom property to change its value depending on context, for example:

/* Define primary hue, saturation, lightness, and alpha for a button */
.btn {
  --h: 156;
  --s: 50%;
  --l: 75%;
  --a: 1;

  background-color: hsla(var(--h), var(--s), var(--l), var(--a));
}

/* Higher specificity, overrides color lightness */
.btn:hover {
  --l: 50%; /* Make button darker on hover */
}

/* Higher specificity, overrides color saturation */
.btn[disabled] {
  --s: 0; /* Make button gray when disabled */
}

/* Define font size for mobile */
:root {
  --fs-s: 0.8rem;
  --fs-m: 1rem;
  --fs-l: 1.5rem;
}

/* Same specificity but declared later, overrides previous font sizes for bigger screens */
@media (min-width: 768px) {
  :root {
    --fs-s: 0.9rem;
    --fs-m: 1.125rem;
    --fs-xl: 2.25rem;
  }
}

JavaScript interactions

You can get the value of a custom property in JavaScript using the getPropertyValue() method:

const button = document.querySelector('.btn');

// from inline style
return button.style.getPropertyValue('--clr-disabled');

// from wherever
return getComputedStyle(button).getPropertyValue('--clr-disabled');

You can update the value of a custom property in JavaScript using the setProperty() method. This method takes two arguments: the name of the custom property and the value to set:

const root = document.documentElement;
root.style.setProperty('--clr-primary', 'red');

This utility of custom properties came in handy in my recent gradient color mixer toy. As shown below, there’s a range input element with a linear gradient background. When users slide the thumb, the background color of .slider::-webkit-slider-thumb and .slider::-moz-range-thumb are updated to match the color of the .slider’s background color at the exact point of the input value. This color has to be calculated in JavaScript.

HTML elements for a range slider.

Normally, we could update an element’s background color easily in JavaScript like this:

element.style.backgroundColor = 'red'; 

However, there’s not an easy way to access pseudo elements in JavaScript. So I can’t get .slider::-webkit-slider-thumb or .slider::-moz-range-thumb. Instead, I used JavaScript to update the value of a custom property, which is then used by the pseudo elements:

// JavaScript

const range = document.querySelector('.slider');

const applyColors = (value) => {
  const colors = getColorsByValue(value);

  range.style.setProperty('--slider-thumb-background', colors.finalValue);
  ...
}

range.addEventListener('input', (e) => {
  applyColors(e.target.value);
});
/* CSS */

.slider::-webkit-slider-thumb {
  background-color: var(--slider-thumb-background, #000);
}

.slider::-moz-range-thumb {
  background-color: var(--slider-thumb-background, #000);
}

If I used React, I would just change the custom property inline:

import {getColorsByValue} from './utils';

const Slider = () => {
  const [thumbHex, setThumbHex] = useState('#000');

  const updateColor = (e) => {
    const value = e.target.value;
    const colors = getColorsByValue(value);
    setThumbHex(colors.finalValue);
  };

  return (
    <input
      type='range'
      value={value}
      ...
      onChange={updateColor}
      style={{'--slider-thumb-background': thumbHex}}
      className='slider'
    />
  );
}

export default Slider;

Use CSS custom properties for theming

Theming usually refers to the ability to modify design tokens (colors, fonts, spacing, etc.) to change the look and feel of an application. There are many ways to achieve theming. Some examples:

With recent browser improvements, we now have a way to implement theming without any JavaScript. Let’s take a look.

With :has() pseudo-class

If there’s an element that keeps track of the user-selected theme and the element is always in the DOM, we can use the :has() pseudo-class to apply appropriate theme. This pseudo-class is relatively new. Read about it here.

For example, we have the following markup for users to select a color:

<select id="colors" name="colors">
  <option>Choose a color</option>
  <option value="blue" id="blue">Blue</option>
  <option value="pink" id="pink">Pink</option>
  <option value="purple" id="purple">Purple</option>
</select>

Then we just need to include the following to our CSS file to apply the theme:

:root {
  --clr-bg: white;
  --clr-text: black;

  background-color: var(--clr-bg);
  color: var(--clr-text);
}

:root:has(#blue:checked) {
  --clr-bg: blue;
  --clr-text: white;
}

:root:has(#pink:checked) {
  --clr-bg: pink;
  --clr-text: white;
}

:root:has(#purple:checked) {
  --clr-bg: purple;
  --clr-text: white;
}

Here’s a demo. Note it doesn’t work in firefox:

This solution completely eliminates JavaScript for our styling needs. However, browser support for :has() is only 83% at the time of writing. It’s not sufficient for production. For better browser coverage, let’s take a look at the following solution.

With data-theme attribute

Using JavaScript, we can add a listener to the <select> element’s change event and update the data-theme attribute on the <body> element to reflect the user-selected theme. We declare a set of CSS custom properties for each [data-theme=<color>] selector so when the attribute’s value changes, the corresponding CSS properties get used. Here’s the approach in React:

import { useState, useEffect } from 'react';

const App = () => {
  const [color, setColor] = useState(null);

  const options = [
    { value: 'blue', label: 'Blue' },
    { value: 'pink', label: 'Pink' },
    { value: 'purple', label: 'Purple' },
  ];

  // On first render and as a side effect of color value changes, update body's data-theme attribute
  useEffect(() => {
    document.body.dataset.theme = color;
  }, [color]);

  const selectColor = (e) => {
    const color = e.target.value;
    setColor(color);
  };

  return (
    <select id="colors" name="colors" value={color} onChange={selectColor}>
      <option value=''>Choose a color</option>

      {options.map((option) => (
        <option key={option.value} value={option.value} id={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
};

We’ll also have to change the CSS to use the data attribute selectors instead:

body {
  --clr-bg: white;
  --clr-text: black;

  background-color: var(--clr-bg);
  color: var(--clr-text);
}

body[data-theme='blue'] {
  --clr-bg: blue;
  --clr-text: white;
}

body[data-theme='pink'] {
  --clr-bg: pink;
  --clr-text: black;
}

body[data-theme='purple'] {
  --clr-bg: purple;
  --clr-text: white;
}

Below is a demo for this approach:

You might feel weird about the React code, because we’re reaching out to the body element from inside React. Additionally, updating the data attribute in a useEffect hook doesn’t feel declarative. However, I failed to find concrete evidence against this approach. In fact, a blog post from Kent C. Dodds (react-testing-library and remix) actually tested it against the ThemeProvider approach. His benchmark shows significant performance advantages of this approach.

However, if the team starts to feel a big productivity hit by using more CSS in their code, we can always switch to the ThemeProvider approach.

Save theme in localStorage

When user leaves the page, their selected color theme will be lost. We can save the theme in localStorage to persist it across sessions. We just need to make some small revisions in the React snippet above to achieve this:

import {useState, useEffect} from 'react';

const App = () => {
  const [color, setColor] = useState(
    JSON.parse(localStorage.getItem('theme')) || null
  );

  ...

  useEffect(() => {
    document.body.dataset.theme = color;

    // Set selected color in localStorage
    localStorage.setItem('theme', JSON.stringify(color));
  }, [color]);
  ...
};

Theming based on type of content

The above solutions work so long as there’s a component that lets users select a theme. What if we want to change the theme based on the type of content present on the page?

In this case, the theme could be derived from the route. The example mentioned at the beginning of this section falls into this category: a more colorful theme for the “Children’s” page of an e-commerce site. To achieve this, we’d probably save the pages we want to apply special themes to and their corresponding themes in a config file or a CMS and change the data-theme attribute when component first renders. To put it simply in code:

import {useEffect} from 'react';

import themeConfig from './theme-config';

const ChildrensCollection = () => {
  useEffect(() => {
    const theme = themeConfig.childrens;
    document.body.dataset.theme = theme;

    // Remove theme value when component unmounts
    return () => {
      document.body.dataset.theme = null;
    };
  }, []);
  ...
};

However, a more common use case is when the theme is derived from data we receive from the backend. For example, we may want to apply a special theme to a product page if it’s on sale, and we’ll only know it’s on sale when data from backend tell us so. In this case, we need to change the data-theme attribute after fetching data:

import {useEffect} from 'react';
import {useParams} from 'react-router-dom';
import {useQuery} from 'react-query';

import themeConfig from './theme-config';

const Product = () => {
  const {id} = useParams();
  const { isLoading, isError, data } = useQuery({
    queryKey: ['products', id],
    queryFn: async () => {
      const res = await fetch(`/api/products/${id}`);

      if (!res.ok) {
        throw new Error('Network response was not ok');
      }

      return res.json();
    },
  });

  useEffect(() => {
    return () => {
      document.body.dataset.theme = null;
    };
  });

  ...

  if (data?.onSale) {
    document.body.dataset.theme = themeConfig.onSale;
  }

  ...
};

In the above example, I used react-query to fetch data. Instead of fetching data in useEffect, React recommends using built-in data fetching mechanism in frameworks or solutions with client-side cache. The latter category includes React Query, useSWR, and React Router 6.4+. Use whichever tool you’d like.

Below is a quick demo for data-based theming. Because we need backend, I used Next.js for this task. In pages/api/products/[id].js, I created a simple API endpoint that gets and returns a product by ID. Once you navigate to the specific product page, a get call will be made to the API endpoint and the data-theme attribute will be updated based on the returned product’s onSale property. The corresponding CSS custom properties are defined for each data-theme attribute in styles/globals.css:

A practical pattern

For real-world projects, the design team will likely provide you with a set of design tokens. They usually include colors; font families, sizes, and weights; spacing; box shadow styles; and even values for animation such as transition-duration. In a themed system, they’re all grouped under the specific theme in the global scope and can be consumed by individual components. A color example:

/*
  Base colors
*/
:root {
  /*
    Variables with --color-base prefix define the hue and saturation values to be used for hsla colors.
    Example:
    --color-base-{color}: {hue}, {saturation};
  */
  --color-base-blue: 212, 100%;

  /*
    Color palettes are made using --color-base variables, along with a lightness value to define different variants.
  */
  --color-blue-10: var(--color-base-blue), 10%;
  --color-blue-20: var(--color-base-blue), 20%;
  --color-blue-30: var(--color-base-blue), 30%;
  --color-blue-40: var(--color-base-blue), 40%;
  --color-blue-50: var(--color-base-blue), 50%;
  --color-blue-60: var(--color-base-blue), 60%;
  --color-blue-70: var(--color-base-blue), 70%;
  --color-blue-80: var(--color-base-blue), 80%;
  --color-blue-90: var(--color-base-blue), 90%;
  --color-blue-100: var(--color-base-blue), 100%;
}

/*
  Default theme
*/
body[data-theme='blue'] {
  --clr-primary: hsla(var(--color-blue-50), 100%);
  /* color used for texts against primary color */
  --clr-contrast: hsla(var(--color-white-100), 100%);
  /* a darker version of primary color */
  --clr-shade: hsla(var(--color-blue-10), 100%);
  /* a lighter version of primary color */
  --clr-tint: hsla(var(--color-blue-90), 100%);
  /* a transparent version of primary color */
  --clr-sheer: hsla(var(--color-blue-50), 10%);
}

/*
  Pink theme
*/
body[data-theme='pink'] {
  /*
    Of course this requires building the pink color palette above like for blue
  */
  --clr-primary: hsla(var(--color-pink-50), 100%);
  ...
}

/*
  Purple theme
*/
body[data-theme='purple'] {
  --clr-primary: hsla(var(--color-purple-50), 100%);
  ...
}

To update individual components based on the global custom property, we just reference the custom property in the component’s CSS. Imagine the following scenario:

To translate the above scenario into code:

.btn {
  ...
  background-color: var(--clr-primary);
  color: var(--clr-contrast);
  ...
}

.btn:hover {
  background-color: var(--clr-shade);
}

.btn:active {
  background-color: var(--clr-tint);
}

We can reference global CSS in the component’s module CSS file. So the button’s background-color and color are controlled by the global custom properties. When the global custom properties’ values update, they’ll automatically update the button’s styles. We achieve this without touching the button’s JSX/TSX file. It’s a great way to keep the component’s API clean and simple.

Here’s a demo of the button component in action:

A couple drawbacks

The design system built with CSS custom properties almost always requires developers to be familiar with existing properties. In writing new components, they’d need to identify the appropriate properties use. The more custom properties we define, the more cognitive load it puts on developers to remember them all. Over time, the theming system may become too complex to maintain. It’s a good practice to limit the scope of theming/customization to at most font size and color.

Additionally, under most circumstances, theming is a nice-to-have and not business critical. While recent browser improvements allow us to implement this feature with little performance cost, an extremely complex system may still cause a performance hit. Only do it if it’s within your performance budget (e.g., lighthouse score stays above 80) without sacrificing features with higher priority.

In conclusion, use new features to progressively enhance your theming system while staying within your performance budget. Monitor its effects on site performance and developer productivity. Don’t be afraid to scale down if it’s not working out.