← All writing
Craft · · 7 min

Custom properties arrive

What can one of these do that a Sass variable can't? I asked the wrong question for nine months.

CSS SCSS

Chrome and Safari both shipped CSS custom properties in March. Firefox has had them for ages. So they’ve been available to me, in most of the browsers I test in, for the better part of a year, and until about three weeks ago I had used them precisely nowhere.

I’ve worked out why, and the reason is stupid enough to be worth writing down. I’d filed them mentally as “Sass variables, but native,” and if that’s what they are then they’re a downgrade. var(--brand) is more typing than $brand. They don’t work in Edge or IE. They ship to the user instead of disappearing at build time. And I already have variables that work everywhere, today, in a tool I’ve used since 2013.

Under that comparison there is no reason to adopt these and I was right to skip them. The comparison is just wrong.

They aren’t variables

The name doesn’t help. The spec calls them custom properties, and the reason they behave strangely if you expect variables is that they are, quite literally, CSS properties that you made up.

Which means they inherit. They sit in the cascade. They can be set on any selector and they apply to that element and everything inside it. And they’re resolved by the browser at computed-value time, on the page, while it’s open, rather than by a compiler on my laptop 20 minutes ago.

They aren't variables the browser happens to understand. They're properties, and properties live in the cascade, and that's the whole difference.

Every useful thing about them falls out of that one sentence, and none of it is available to a preprocessor, because a preprocessor has finished running before any of it happens.

The one Sass can’t win

Here’s the demo that finally landed it for me. A Sass variable inside a media query does not do what a beginner assumes it does:

gutter.scss
$gutter: 1rem;
.card { padding: $gutter; }

@media (min-width: 60em) {
  $gutter: 2rem;      /* changes nothing above; that rule already compiled */
  .card { padding: $gutter; }   /* you have to restate every single one */
}
SCSS

That’s not a bug, it’s just what compile-time means. By the time the media query is evaluated there is no $gutter anywhere, only the string 1rem baked into every rule that used it. So changing a spacing scale at a breakpoint means rewriting each declaration, which is why every project I’ve worked on has a handful of values that are responsive and 40 that are not, and the difference is entirely down to how much anyone could be bothered.

The custom property version is the one you assumed you were writing:

gutter.css
:root { --gutter: 1rem; }

.card       { padding: var(--gutter); }
.stack      { margin-bottom: var(--gutter); }
.grid__col  { padding-left: var(--gutter); }

@media (min-width: 60em) {
  :root { --gutter: 2rem; }   /* all three update. that's it. */
}
CSS

One declaration, and every rule downstream re-resolves. I sat and looked at that for a while.

Three more tricks

Once you see it as a property, the rest follows.

Scoping to a subtree. Set --gutter on .sidebar and every card inside the sidebar is tighter, with no modifier class, no .card--compact, and nothing added to the markup. The component doesn’t need to know it’s in a sidebar, which is the exact contextual-override problem I wrote about while trying to be disciplined about BEM last year, and this dissolves most of it.

A component can expose an API. If I write .card { padding: var(--card-padding, 1rem); } then --card-padding is a documented knob on that component, with a default, that anything containing a card can set. That’s a much nicer interface than adding a modifier for every variation somebody might want, and it means the style guide can say “these are the properties you can set” rather than “here are 11 modifiers.”

And JavaScript can touch them. Not a class swap that flips 40 rules, one property:

theme.js
document.documentElement.style.setProperty('--brand', '#e8734a');
getComputedStyle(el).getPropertyValue('--gutter');   // reading works too
JavaScript

Which is the one that matters commercially, I think. A client who wants to change their accent color, or a site that offers a high-contrast mode, or a white-label thing with six tenants, has always meant compiling six stylesheets and shipping the right one. Now it’s one stylesheet and a line of values, decided at runtime, after the CSS has loaded. Sass cannot do that in principle, not because it’s lacking a feature, but because it isn’t there any more when the question gets asked.

Shipping it in 2016

Edge doesn’t have these yet, and IE11 never will, so on client work this is a progressive enhancement and has to be written like one.

The good news is that the fallback is nearly free, because of how CSS handles declarations it doesn’t understand. Write the static value first, then the var() version. Browsers that don’t parse var() discard the second declaration and keep the first. Browsers that do, take the second.

fallback.css
.button {
  background-color: #2f6f4e;              /* everyone */
  background-color: var(--brand, #2f6f4e); /* dropped by Edge and IE */
}
CSS

Which means the honest way to use them this year is for things that are nice to have rather than essential: the theme color, spacing refinements, a component knob with a sensible default. Anything where the older browser getting the static version is a fine outcome rather than a broken one. If you need to branch harder there’s @supports (--a: 0), which is an odd-looking test and does work.

Sass isn’t going anywhere

Not a migration, and I’ve seen a few people frame it as one. These sit alongside each other and the split turns out to be clean.

Sass variables for anything that’s fixed by the time the page loads. Breakpoint values especially, because custom properties can’t be used in a media query condition at all, which surprises everyone including me. Also anything I need to do build-time math with, color functions, loops, the map I generate a palette from.

Custom properties for anything that should be able to differ per element, per breakpoint, per theme, or per user, while the page is open.

The test I’ve settled on is: does this value need to change after the CSS has been sent. If yes, it’s a custom property. If no, it can be either, and it may as well be the one that costs nothing to ship.

The wrong question for nine months

The reason I lost nine months on this is that I kept asking “what does this replace,” and the answer is nothing, and a thing that replaces nothing looks like a thing you don’t need.

The question that got me somewhere was “what could I not do before,” and the answer to that is a decent list: change a value at a breakpoint once, scope one to a subtree, hand a component a knob, let a person change a color and see it happen.

Sass was a workaround for CSS not having variables. These aren’t the standardized version of that workaround. They’re a different feature that happens to answer the same complaint, and I’d have got there faster if the spec authors had called them literally anything else.

Read similar posts
6 min

Nesting is not Sass nesting

It looks close enough to what I've been writing since 2014 that I typed a BEM selector into it out of habit. The reason that doesn't work is the whole difference.

6 min

@extend considered harmful

The Sass feature that looks like it saves you bytes will quietly rewrite the order of your selectors, move your rules to a part of the file you didn't write them in, and refuse to work inside a media query.