Variables in CSS have been a highly requested feature of the World Wide Web Consortium’s CSS Working Group, which I joined in 2012, since the group’s humble beginnings in 1997. By the late 2000s, the developer community—in its quest to reduce duplication and streamline work—had devised many solutions to CSS’s lack of variables: first custom PHP scripts, then preprocessors like Less and Sass. The Working Group knew that a native solution would reduce the need for tooling; we still needed to address variables. The first working draft of the CSS variables module was published around 2012, and CSS custom properties for cascading variables (as it was more accurately renamed) finally gained traction around 2017 with browser support parity.
Today, however, CSS variables remain poorly understood. After reading this piece, I hope you’ll better understand the differences between declarative CSS variables and variables in other programming languages, and how you can leverage their power. Onward!
CSS variables in a nutshell
CSS variables are custom properties that cascade normally and even inherit. They start with a reserved --
prefix, and there are no real rules about their value. (Anything goes, even white space.) They’re loosely parsed at declaration time, but error handling isn’t done until they’re used in a noncustom property. Their values are referenced via the function var(--name)
, which can be used in any CSS property. The var()
function also supports a second argument, a fallback in case the variable isn’t set.
What about browser support?
CSS variables are currently supported for 93 percent of users globally. If a browser doesn’t support CSS variables, it also doesn’t understand the var()
function, and doesn’t know what its second argument means. Instead, we need to use the cascade, as we do for every new CSS feature. Take this example:
background: red;
background: var(--accent-color, orange);
Depending on the browser and the value of --accent-color
, there are four possible outcomes. First, if the browser doesn’t support CSS variables, it will ignore the second line and apply a red background. Second, if the browser does support CSS variables and --accent-color
is set, that color will become the background. Third, if the browser supports CSS variables and --accent-color
is not set, orange will be used—it’s the var()
fallback. Fourth, if the browser supports CSS variables and --accent-color
is set, but to a nonsensical value for the property (e.g., 42deg
), the background will be—wait for it—transparent.
The last outcome may not make immediate sense. Why transparent? After all, if we’d written this:
background: red;
background: 42deg;
We’d get red, since the second line would be thrown away, along with everything the browser doesn’t understand.
In the code snippet that includes 42deg
with no variable, the browser will throw it away at parse time. However, with a variable, the browser won’t know whether the declaration is valid until later. By then, it has thrown away any other cascaded values (since it only holds one computed value) and reverted to the initial value—in this case, transparent.
When fallback values don’t cut it
Fallback values for older browsers work for simple use cases, but CSS feature queries—the @supports
rule—can provide them entirely different CSS. Consider the following example, which sets a red background in browsers that donʼt support CSS variables and a green one in browsers that do:
html { background: red; }
@supports (--css: variables) {
html { background: green; }
}
Feature queries have a negative version, too, which allows us to conditionally add CSS only to browsers that don’t support CSS variables by writing @supports not (--css: variables).
However, in this case, it would only be read by the browsers that both support feature queries and don’t support CSS variables, a rather small set.
How do CSS variables differ from preprocessor variables?
CSS preprocessors are basically programs that are executed once, since it’s static CSS code that they generate and send down the wire. They behave similarly to imperative programming language variables, with lexical scoping and multiple values over the course of the execution. They can be used anywhere in the stylesheet: selectors, conditionals, properties, values, and so on, even to generate just part of a value or selector.
In contrast, CSS variables can only be used in values, and only for whole tokens. They’re reactive and remain live throughout the lifetime of the page. They use dynamic scoping on a per-element basis and cannot be part of imperative calculations since they only have one value for every given state. When CSS variables are set externally (e.g., through HTML or JavaScript), use them for pure data, not CSS values like lengths or percentages.
Dynamic instead of lexical scoping
Variable scope in preprocessors boils down to nested curly bracket blocks. However, because CSS variables are properties, their scoping (unlike that of preprocessor variables) is DOM-based. That means CSS variables are resolved per element, not per scope, and they inherit like normal properties. Take the following example of CSS variables:
body {
--shadow-color: gray;
}
button {
box-shadow: .1em .1em .1em var(--shadow-color);
}
button:hover {
--shadow-color: skyblue;
}
The button’s gray shadow becomes sky blue when it’s hovered over. Let’s try to convert this to the preprocessor language Sass:
body {
$shadow-color: gray;
}
button {
box-shadow: .1em .1em .1em $shadow-color;
}
button:hover {
$shadow-color: skyblue;
The result is a syntax error: “undefined variable on line 6.” Sass doesn’t know that <button>
is inside <body>
(because it’s not executed with the HTML context that CSS has in the browser), or that a button:hover
is also a button.
CSS variables are reactive
The most important difference with preprocessor variables is that CSS variables are reactive. They remain live throughout the lifetime of the page, and updating them updates every relationship that references them. In that sense, they’re more similar to properties in reactive frameworks like Angular, Mavo, and Vue than to variables in conventional programming or preprocessor languages. Because they’re properties, they can be updated via any mechanism that updates CSS properties: stylesheets, inline styles, even JavaScript.
Cycles make CSS variables invalid at computed value time
Consider the following Sass snippet:
.foo {
$i: 1;
z-index: $i;
$i: $i + 1;
z-index: $i;
The output would be something like this:
.foo {
z-index: 1;
z-index: 2;
CSS variables are a completely different story, a natural consequence of their reactivity. Let’s look at the same snippet, but with CSS variables:
.foo {
--i: 1;
z-index: var(--i);
--i: calc(var(--i) + 1);
z-index: var(--i);
}
A variable depending on itself would create an infinite loop, which we can’t have in a declarative language like CSS. To avoid this, cycles render all variables involved as invalid at computed value time. This includes cycles of length 1 (such as the example above) and longer cyclic chains, where variable A depends on B, which depends on C, and so on until Z—which depends on A. All 26 of these would be invalid at computed value time, which would make their value equal to initial
, and which would essentially have the same result as if they were never set.
CSS variables facilitate true separation of behavior and style
The reactivity of CSS variables isn’t merely a philosophical distinction; it makes them powerful. Used properly, styling can stay in the CSS and computation in the JavaScript, where each belongs. To make this concept less abstract, suppose we wanted a radial gradient background in which the center point of the gradient follows the mouse cursor. In the past, we’d need to generate the entire gradient in JavaScript and set it on the root
element’s inline style every time the mouse moves. With CSS variables, the JavaScript only needs to set two CSS variables: --mouse-x
and --mouse-y
. In vanilla JavaScript, it might look like this:
var root = document.documentElement;
document.addEventListener("mousemove", evt => {
let x = evt.clientX / innerWidth;
let y = evt.clientY / innerHeight;
root.style.setProperty("--mouse-x", x);
root.style.setProperty("--mouse-y", y);
});
The designer(s) can then tweak the effect to their heart’s content without needing to communicate with the developer(s). For instance, it could go from this:
html {
min-height: 100vh;
background: radial-gradient(
at calc(100% * var(--mouse-x, .5)) calc(100% * var(--mouse-y, .5)),
transparent, black 80%) gray;
}
See this effect in action on CodePen
To a completely different effect, with layered gradients that use the same center point:
html {
min-height: 100vh;
--center: calc(100% * var(--mouse-x, .5)) calc(100% * var(--mouse-y, .5));
background: radial-gradient(circle at var(--center), yellowgreen, transparent 3%),
conic-gradient(at var(--center), yellowgreen, green, yellowgreen);
}
See this effect in action on CodePen
Suppose we wanted the hues and angle in a conic gradient to vary based on the time of day. The same JavaScript would work for the mouse, and we could add a bit of JavaScript that sets a CSS variable with the current hour (0–23):
var updateHour = () => {
var hour = new Date().getHours();
root.style.setProperty("--hour", hour);
};
setInterval(updateHour, 60000);
updateHour();
We could then include that variable in our CSS and use it in calculations:
html {
min-height: 100vh;
--center: calc(100% * var(--mouse-x, .5)) calc(100% * var(--mouse-y, .5));
--hue: calc(var(--hour) * 15);
--darkColor: hsl(var(--hue), 70%, 30%);
--lightColor: hsl(var(--hue), 70%, 50%);
background: conic-gradient(at var(--center), var(--darkColor) calc(var(--hour) / 24 * 1turn), var(--lightColor) 0);
}
See this effect in action on CodePen
Because all three variables set via JavaScript are pure data and not CSS values, we can also use them in multiple unrelated CSS rules. (They’re not specific to our background effect.)
CSS variables facilitate style encapsulation
CSS variables also make it possible to reuse and customize CSS code, since they make encapsulation possible. Suppose we’ve created a flat button style applied with the class .flat
. Its (simplified) CSS code looks like this:
button.flat {
border: .1em solid black;
background: transparent;
color: black;
}
button.flat:hover {
background: black;
color: white;
}
Say we want different colored buttons for different actions, such as a red button for a dangerous action. We could support a .danger
modifier class and override the relevant declarations:
button.flat.danger {
border-color: red;
color: red;
}
button.flat.danger:hover {
background: red;
color: white;
}
To avoid duplication, let’s replace the color with a variable:
button {
--color-initial: black;
border: .1em solid var(--color, var(--color-initial));
background: transparent;
color: var(--color, var(--color-initial));
}
button:hover {
background: var(--color, var(--color-initial));
color: white;
}
Now theming is a matter of overriding one property: --color
:
button.flat.danger {
--color: red;
}
We could even create themes on the fly, by overriding the --color
property on the button’s inline style.
While this is a triumph of conciseness, it’s a far greater triumph of encapsulation—a luxury which CSS didn’t always afford its developers. In fact, theming third-party CSS code used to mean becoming intimately familiar with its internal structure, and changing third-party code required similarly extensive changes in the theming code. Now CSS custom properties can serve as an API of sorts: dramatically changing the underlying style with custom properties alone.
Circling back to our button example, suppose we want to add a transition for a smoother hover effect. We also want the new background color to grow inward from the border, instead of the default fade we get when transitioning between two background colors. To accomplish this, we need to fake the background with an inset box-shadow
:
button.flat {
--color-initial: black;
border: .1em solid var(--color, var(--color-initial));
background: transparent;
color: var(--color, var(--color-initial));
transition: 1s;
}
button.flat:hover {
box-shadow: 0 0 0 1em var(--color, var(--color-initial)) inset;
color: white;
}
Despite our implementation changing fairly substantially, all of the buttonʼs different color themes work. If we had needed to override the CSS for theming, then our theming code would have broken.
This ability—not saving a few characters or time on maintenance—is the true value of custom properties. They enable us to share code that can be reused and customized by other developers while still allowing us to completely change its internal structure. Custom properties are also the only way to theme shadow DOM components because, unlike any other CSS properties, they transcend shadow DOM boundaries.
Creating color palettes
Typically, style guides start from one or a few base accent colors, from which the rest of the palette is drawn. Using variables for only the base colors is more efficient than having to manually tweak each color variation. Unfortunately, CSS doesn’t yet have color modification functions. (They’re coming in CSS Color 5, edited by yours truly!) In the meantime, instead of defining our base color as one variable, we need to use separate variables for the color components.
Out of the color syntaxes currently available to CSS, hsl()
tends to work better for creating color variations (until we get lch()
, which is far superior due to its wider range and perceptual uniformity). If we anticipate needing only lighter/darker and alpha variants, we can use a variable for both hue and saturation:
:root {
--base-color-hs: 335, 100%;
--base-color: hsl(var(--base-color-hs), 50%);
--base-color-light: hsl(var(--base-color-hs), 85%);
--base-color-dark: hsl(var(--base-color-hs), 20%);
--base-color-translucent: hsla(var(--base-color-hs), 50%, .5);
}
We can use these variables throughout our CSS or create new variations on the fly. Yes, there’s still a little duplication—the base color lightness—but if we plan to create many alpha variations, we could create a variable with all three coordinates, or one with the lightness.
Preventing inheritance
When CSS variables are used to hold data, their default behavior—inheritance—is desired: We define the variables on the root element and can override them on a subtree by redefining them. But inheritance often gets in the way. Consider the following example, which uses a variable to apply a subtle glow with a predefined color, offset, and blur radius, but with variable spread:
* {
box-shadow: 0 0 .3em var(--subtle-glow) gold;
}
p {
font: 200%/1 sans-serif;
--subtle-glow: .05em;
}
See this (corrected) effect in action on Dabblet
This causes the subtle glow to be applied not only to the <p>
element but also to any of its children, including <a>
links, <em>
emphasis, and so on. Whoops.
To fix it, we need to disable inheritance by adding --subtle-glow: initial
to the first rule. Since direct application always takes priority over inherited rules, it will override inherited values, but due to the low specificity of *
, it will give way to anything specified on the element.
The magical future of CSS custom properties
Custom properties are a powerful and well-supported addition to CSS, and their true potential has yet to be fully explored. Houdini, a task force hybrid of the W3C Technical Architecture Group and the CSS Working Group, is working on APIs that would extend “magic” aspects of the rendering engine (that is, those that were previously inaccessible via code), many of which will be available in the near future. Imagine, for instance, the cool effects we’ll be able to create when we can animate CSS variables! The show is just starting.