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 can be updated in JavaScript, which unlocks endless possibilities in dynamic styling.
- They’re scoped to the element where they’re declared and conform to the cascade, which means their values can be changed in the stylesheet after they’re defined.
They also share some of the same benefits as preprocessor variables:
- They allow a value to be stored in one place, then referenced in multiple other places. This means improved code maintainability especially in complex projects.
- They serve as semantic identifiers and provide contexts, e.g.,
var(--clr-success)
is easier to read and understand than#00ff00
.
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:
- Custom properties must be declared within a scope (inside curly brackets). To make them available globally, developers usually define them inside the
:root
pseudo-class. It’s the same ashtml
but with a higher specificity. - Custom properties are case sensitive. So
--clr-primary
and--CLR-PRIMARY
are two different properties.
/* 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.
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:
- We can use a media query
@media (prefers-color-scheme: dark)
to query user’s browser preference and apply a dark theme automatically. - We can provide a set of pre-defined themes and let the user choose one, e.g., through a light/dark toggle or a
<select>
element like in Twitter, etc. - We can give the user more freedom in customizing the theme by letting them supply whatever value they want for a primary design token. The primary value will then change all other values accordingly, e.g.,
--font-size-large
is--font-size-medium
scaled by 1.5. - Themes don’t need to be specified by the user. We can display different themes depending on the content of the page. For example, a more colorful theme is automatically applied when the user navigates to the “Children’s” section of our e-commerce site.
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:
- We defined all our design tokens in our global CSS in custom properties.
- We have a button component that has a primary color background and a text color that’s the background’s contrast.
- On hover, the button’s background turns darker and it turns lighter on active state.
- Our app allows users to switch between different themes.
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.