Skip to content

Using CSS Custom Properties with Fallbacks for Efficiency

Less boilerplate, fewer surprises.
Daine Mawer||3 min read|571 words

The short answer

CSS custom properties get more useful with a fallback value: var(--button-bg, var(--color-primary)) instead of just var(--button-bg). It makes a property optional per component instead of redefining every value in every variation, which means less CSS shipped and less boilerplate to maintain.

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) {}

Further reading

Takeaways

  1. A custom property's second argument in var() is a fallback, not decoration. var(--button-bg, var(--color-primary)) falls back to the primary color if --button-bg isn't set.
  2. This makes each property effectively optional per variation, so a secondary button class only needs to override the two or three properties that actually change.
  3. Less boilerplate means less CSS shipped over the wire. That's a real, if modest, performance win at scale.
  4. Fallback support has been baseline since April 2017, so this works in every browser you're likely to still support.

Questions

What does the second argument in CSS var() do?

It's a fallback value. var(--button-bg, var(--color-primary)) uses --button-bg if it's defined, and falls back to --color-primary if it isn't. Most people only ever use the first argument.

Do I need a @supports check for CSS custom property fallbacks?

No, not for basic use. Fallback values in var() have been baseline since April 2017. A @supports(--css: variable) {} check only matters if you're still supporting genuinely old browsers.

Does this actually improve performance?

A little. Fewer redeclared properties per variation means a smaller stylesheet, which matters more as a component library grows. It's not a dramatic win on its own, but it compounds.