Dec 15

CSS Custom Properties

Also known as "CSS variables," this nifty modern CSS feature enables incredible flexibility throughout your stylesheets.

By Stephanie Eckles

CSS custom properties are very well supported and are absolutely something to make a priority to learn and use in 2022.

There are many things to love about custom properties, and we'll overview the following:

What are CSS custom properties?

Custom properties allow you to define variables for re-use in your styles. Just like other CSS properties, they abide by the rules of the cascade. This is a great feature because it means we can define them with defaults and then provide overrides as needed. If you're a JS dev, think of them as equivalent to being defined with let.

To define a custom property, use the name of your choice preceded by two dashes, for example: --my-property. Then you can use that value by calling it within the var() CSS function in place of providing a standard value.

For this class, we're defining a custom --color property and then using it for the color value:

.btn {
--color: blue;
color: var(--color);
}

How custom properties differ from preprocessor variables

Preprocessors like Sass or LESS have had variables for many years. However, those variables are ultimately static once compiled into your final stylesheet. This is great for values that you repeat across your styles but don't have a need to change post-compilation.

Custom properties have the advantage for values that may change dynamically client-side or contextually via inline style overrides. Additionally, using preprocessor variables to set a value such as our button color requires redefining all properties used in a modifier class to change to a different preprocessor variable. In other words, you can't change the value of a preprocessor variable after it was defined, but you can do that with CSS custom properties (pending cascade inheritance).

Another advantage for preprocessor variables over custom properties is defining strings to use anywhere, such as for media query breakpoints, or to even define a selector string which can look like the following:

// Example in Sass which allows overriding the $classes value
$classes: ".class-a, .class-b" !default;
#{$classes} {
// define properties for this rule
}

Why should I use CSS custom properties?

Let's answer that by enhancing our previous code example for changing the .btn color.

If I want to change the color of the button text and border on hover, I no longer need to repeat those properties. Instead, we can just change the --color value.

Notice how we're able to use the var() function as only part of the value, in this case, to define the border-color. This opens up endless opportunities for custom properties!

CSS to update color variable for hover state
.btn {
--color: blue;

color: var(--color);
border: 2px solid var(--color);
padding: 0.25em 0.5em;
border-radius: 0.25em;
text-decoration: none;
}

.btn:hover {
--color: red;
}

Using custom properties to set defaults

A feature of var() is the ability to both call a custom property, but also set a fallback which you can also think of as a default.

var(--color, blue)

We can even set the default using another custom property. Using that idea, let's create a different version of our .btn class to allow modifying the --border-color while also setting the --color variable as a default.

Combining local variables with defaults
.btn-v2 {
--color: blue;

color: var(--color);
border: 2px solid var(--border-color, var(--color));
padding: 0.25em 0.5em;
border-radius: 0.25em;
text-decoration: none;
}

.btn-v2:hover {
--border-color: red;
}

Custom properties and inheritance

It's important to learn a few things about how CSS custom properties are computed and inherited.

My own introduction into custom properties led me to believe that I should load them all on the :root selector. Instead, it's recommended to only hold truly global properties in the :root, and more class or component-specific styles closer to where they are needed.

A key concept is that custom property values are computed once per element, and then the computed value is available for inheritance. When you use custom properties within calc() or other values that need to be calculated, like the hue of hsl(), then you are making the total computed value inheritable. You are not able to change a value within a calculation if it is set on an ancestor, as shown:

:root {
--unit: 10px;
--size-lg: calc(3 * var(--unit));
}

/* this will not use the updated unit value for the calculation */
.margin-top-3xl {
--unit: 30px;
margin-top: var(--size-lg);
}

One option to fix the previous inheritance issue is by using a combination of a base and modifier class so that the values are computed against the same element. In the demo, we're still setting a global --unit value and using it as the default on the base class.

Handling custom property inheritance
:root {
--unit: 10px;
}

.margin-top {
--margin-unit: var(--unit);
--multiplier: 1;

margin-top: calc(var(--multiplier) * var(--margin-unit));
}

.margin-top--3xl {
--margin-unit: 30px;
--multiplier: 3;
}

div[class*="margin"] {
background-color: dodgerblue;
color: white;
padding: 0.5rem;
font-size: 1.5rem;
}
.margin-top
.margin-top.margin-top--3xl

There are also times where it can be beneficial to use an undefined custom property, such as making utility classes even more flexible. The benefit of an undefined property is that it can inherit its value from any ancestor, which is excellent for updating values for a group of related elements.

Two options for using unset properties:

  1. Completely undefined with no default, which means it will likely have the behavior of unset for that property, for example: color: var(--color) where --color is not set within the rule
  2. Undefined with a fallback to get the benefits of both inheritance and ensuring a default, example: color: var(--color, blue)

We used the second option in the demo around setting defaults to allow an optional --border-color value.

A gotcha with the first is that you may expect it to use a previously set style for that element in your stylesheet. However, by the time the custom property is evaluated the browser will have already tossed out a previously set, non-inherited value.

In this demo, if --color is not set, all paragraphs will use unset behavior. For paragraphs, this evaluates to using inherit for color. Since the custom property caused throwing out the previously set paragraph style, the color will inherit from the nearest ancestor instead. You can experiment with adding --color to the paragraph using dev tools to see it successfully set and used. Adding via dev tools works due to causing a repaint.

Demonstration of custom properties using 'unset' behavior
/* tossed out */
p.unset { color: red }
/* behaves as 'inherit' per 'unset' rules */
p.unset { color: var(--color) }

My color will inherit from the nearest ancestor where it is set

Accessing and setting custom properties with JavaScript

There are typically JavaScript counterparts for interacting with CSS, and using JS to dynamically set or update custom properties is kind of a superpower. We can keep the overall style setup in our CSS and update parts of it as needed.

Some opportunities to update custom properties with JavaScript:

To access the value of a custom property within JavaScript, use the following combination of getComputedStyle() with getPropertyValue().

getComputedStyle(element).getPropertyValue("--my-var");

In this next demo, we're using JavaScript to get the width of the paragraph. Then we assign that to the custom property assigned as the hue value within the hsl() color function. As an aside, this works because hsl allows any numeric value for hue because it just loops back around the color wheel for numbers greater than 360.

Updating custom properties with JavaScript
.js-color {
background-color: hsl(var(--hue), 100%, 80%);
color: black;
font-size: 1.5rem;
padding: 1rem;
}

.js-color::after {
content: " Current: " attr(style);
}
const p = document.querySelector('.js-color');
const setColor = () => p.style.setProperty('--hue', p.offsetWidth);
window.onresize = setColor;
setColor();

Resize your viewport to change my background color (warning: flashing possible).

Combining preprocessor variables and CSS custom properties

As a software engineer who works on design systems, I've grown fond of using Sass together with custom variables so that I can use the strengths of each.

In order to use a Sass variable as a custom property value, you need to use interpolation which looks like this:

--custom-property: #{$sass-var};

In my work, I'm usually including theming capability. With Sass, we can set static variables using the !default flag which means they are overridable. Then, we can pass that in to be used as custom property values. Plus we can take advantage of other Sass features like looping to help produce similar groups of properties.

$color-link: blue !default;
$font-sizes: (
"small": .875rem,
"normal": 1rem,
"medium": 1.25rem,
"large": 2rem
) !default;

:root {
--color-link: #{$color-link};

@each $size, $value in $font-sizes {
--font-size-#{$size}: #{$value};
}
}

This setup allows re-theming an application without modifying the core Sass file setup.

Summary of ways to use custom properties

Additional resources