Using CSS Custom Properties with Fallbacks for Efficiency
Most of us are used to CSS custom properties by now. Since they landed in 2017, custom properties (CSS variables) have cut down on boilerplate and given engineers a lot more control over the values inside a rule.
They can still be tricky to work with, and they're not always as intuitive as you'd hope. Used well, though, they cut a surprising amount of boilerplate.
The approach below isn't perfect, but it holds up well in real projects.
Why use CSS custom properties
In my experience there are really only three reasons to reach for one:
- You want to reuse a value in more than one place
- You know a value will change later and want to avoid a big refactor
- You need to store a dynamic value set from JS
Point three isn't really the focus here. Points one and two are the ones that matter. Let's look at the classic example: a button.
The infamous button
Buttons are notoriously hard to style consistently, even though they're one of the simplest HTML elements around. Maybe it's because they carry so many states and variations. Either way, it's usually the first component a frontend engineer touches on a fresh build. Here's a typical way to style one with custom properties:
:root {
--color-primary: purple;
--color-text: black;
--color-text-inverted: white;
--color-border: gray;
}
.button {
--button-bg: var(--color-primary);
--button-color: var(--color-text-inverted);
--button-border: var(--color-border);
align-items: center;
background-color: var(--button-bg);
border: 1px solid var(--button-border);
color: var(--button-color);
display: inline-flex;
line-height: 1;
}
Technically, this is fine. It's clean, and it'd get a 👍 in a code review. But can it be tighter? Yes.
The var() function is more capable than most people give it credit for. We tend to use it in a one-dimensional way, rendering out a single value, but it can do more than that with a fallback value.
Take the same example and rewrite it:
:root {
--color-primary: purple;
--color-text: black;
--color-text-inverted: white;
--color-border: gray;
}
.button {
align-items: center;
background-color: var(--button-bg, var(--color-primary));
border: 2px solid var(--button-border, var(--color-border));
color: var(--button-color, var(--color-text-inverted));
cursor: pointer;
display: inline-flex;
line-height: 1;
padding-block: 0.875rem;
padding-inline: 1rem;
}
That's cleaner, and it produces the same result with less boilerplate. Overriding a property inside a variation effectively makes every locally scoped custom property optional. Now a variation only needs to define what actually changes:
.button:where(.button-secondary) {
--button-bg: orange;
--button-border: teal;
}
.button:where(.button-tertiary) {
--button-bg: teal;
--button-border: orange;
}
None of this is revolutionary, but it matters how much compressed text ships over the wire. A smaller CSS file is a smaller CSS file: fewer lines, less to parse, less to maintain. The first example needed almost double the code to do the same job as the fallback version.
That adds up. Less CSS means a small but real performance win, and it's easier to read besides. If you want to poke at it yourself, here's a CodePen (opens in a new tab) to fork.
Browser support
Fallback values in var() have been baseline since April 2017, per Google Baseline (opens in a new tab), so they work in every browser worth supporting today. I wrote more about what Baseline actually means if you want the full picture.
If you do need to check for support explicitly, this is the statement:
@supports(--css: variable) {}