← All writing
Craft · · 10 min

Picking the green on a call

CSS SCSS

A client spent most of a call last week deciding whether their green was too cold. I spent that same call in DevTools, changing the green one rule at a time, while everybody watched me scroll.

It was one rule at a time because the brand color is a Sass variable, which is a beautiful thing right up until the compiler finishes with it. Out the other end comes the same hex string sitting in every rule that ever used $brand, and DevTools has no idea those are related to each other. Button background. Link color. The border under the active tab. A little icon fill I forgot about entirely and found on the third pass. I’d change one, they’d say warmer, I’d go find the next one, and by the time the page agreed with itself nobody could remember what the first version had looked like.

I know, I know, I could have opened the Sources panel and done a find-and-replace across the compiled stylesheet. On a staging build, on a call, while everyone waits. Sure.

The other option is to recompile and push to staging between each round, which takes a few minutes, and a few minutes is an extremely long time when it’s quiet and everyone can hear it. I’m two months into a new agency and this was not the impression I was hoping to make lol

Cheating in Chrome

So before the next one of these calls, I pulled the brand color out into a custom property, on the staging build only.

brand.css
:root { --brand: #2f6f4e; }

.button           { background-color: var(--brand); }
.tab--active      { border-bottom-color: var(--brand); }
.prose a          { color: var(--brand); }
CSS

One swatch, at the top of the file, and the whole page follows it. Client says warmer, I drag the picker, everything moves together, and we’re done in about the time it used to take me to find the second declaration.

And it doesn’t matter even slightly that Edge doesn’t support this and IE11 never will, because that build was never going to a person. It ran on staging, in my Chrome, on my screen, for one meeting, and then I threw it away. Browser support is an argument about what you hand over. This wasn’t that.

Which is a pretty undignified way to finally understand a feature. I wrote a month ago that I don’t really evaluate tools, I mostly just pick up whatever happens to be in front of me. This is the other failure mode and I like it even less. Custom properties landed in Chrome and Safari back in March and Firefox has had them for ages. So they’ve been available in most of the browsers I test in for most of this year, and until that call I had used them precisely nowhere.

I’ve figured out why, and it’s dumb enough to be worth writing down. I’d filed them as Sass variables with a worse syntax, and under that filing they are genuinely a downgrade. var(--brand) is more typing than $brand. Two browsers I have to support don’t have them. They go over the wire to the user instead of disappearing at build time. And I’ve had variables that work everywhere since my first week on the job.

Every one of those is true. The filing is just wrong. Or who knows, maybe it’s a skill issue on my part.

Property values

The name is doing nobody any favors here. The spec calls them custom properties, and the reason they behave strangely when you’re expecting variables is that they are properties. CSS properties, that you made up.

Which means they inherit. They sit in the cascade, they can be set on any selector, they apply to that element and everything inside it, and they get resolved by the browser at computed-value time, on the page, after the stylesheet has already loaded, instead of by a compiler on my laptop 20 minutes earlier.

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

Ask again at 60em

The demo that made it click for me is one a beginner writes by accident. A Sass variable inside a media query does not do the thing you assume it does.

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

@media (min-width: 60em) {
  // changes nothing above, that rule compiled ages ago
  $gutter: 2rem;
  // so you restate every single one by hand
  .card { padding: $gutter; }
}
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. Changing a spacing scale at a breakpoint means rewriting each declaration one at a time, which is why every project I’ve worked on has a handful of values that are responsive and a few dozen that aren’t, and the difference is entirely how much anybody felt like doing that day.

The custom property version is the one you thought 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) {
  /* all three update, and that's the entire change */
  :root { --gutter: 2rem; }
}
CSS

One declaration, and every rule downstream re-resolves. I sat and looked at that for longer than I’d like to admit to.

But wait, there’s more

Once you see it as a property, the rest is just the cascade doing what the cascade already does.

You can scope one to a subtree. Set --gutter on .sidebar and every card inside the sidebar gets tighter, with no modifier class, no .card--compact, and nothing added to the markup at all. The component doesn’t have to know it’s in a sidebar, which is most of the contextual-override mess I was writing about BEM to escape last year.

A component can also publish its own settings. If I write .card { padding: var(--card-padding, 1rem); } then --card-padding is a documented value with a sensible default, and anything containing a card can set it. That’s a much better interface than adding a modifier for every variation somebody might one day want. It means a style guide can say “here are the values you can set” instead of listing 11 modifiers, assuming anyone opens the thing.

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

theme.js
document.documentElement.style.setProperty('--brand', '#e8734a');

// reading works too
getComputedStyle(el).getPropertyValue('--gutter');
JavaScript

That last one is the one that’ll matter at work, I suspect. A client who wants their accent color changed, or a site offering a high-contrast mode, or a white-label thing with a dozen tenants, has always meant compiling a dozen stylesheets and serving whichever one applies. Now it’s one stylesheet and a short block of values, decided on the page, after the CSS has loaded. Sass can’t do that in principle, not because it’s missing a feature, but because it isn’t there anymore when the question gets asked.

Edge cases

Edge doesn’t have these yet and IE11 never will, so anything actually going to a client site this year is a progressive enhancement and has to be written like one.

The nice part is that the fallback is nearly free, because of how CSS handles a declaration it can’t parse. Write the static value first, then the var() version. Browsers that don’t understand var() throw the second declaration on the floor and keep the first. Browsers that do, take the second.

fallback.css
.button {
  /* everybody gets this one */
  background-color: #2f6f4e;
  /* Edge and IE never see this one */
  background-color: var(--brand, #2f6f4e);
}
CSS

None of which precludes using them today, it just decides what for. The honest uses on client work right now are the ones that are nice to have instead of essential, so the theme color, spacing refinements, a component default, anywhere an old browser getting the static value is a fine outcome instead of a broken one. If you need to branch harder there’s @supports (--a: 0), which looks like a typo and does work.

The Sass and the bathwater

I’ve seen a couple of people frame this as a migration and I don’t think it is. The two coexist fine, and the split is cleaner than I expected.

Sass variables for anything that’s settled by the time the page loads. Breakpoint values especially, since a custom property can’t be used in a media query condition at all, which surprises everybody including me. Also anything that needs math at build time, the color functions, the loops, the map I generate a whole palette out of.

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

The test I’ve settled on is whether the value needs to change after the CSS has been sent. If yes, it’s a custom property. If no, either will do, and it may as well be the one that costs nothing to serve.

The question I should have asked

I lost nine months on this because I kept asking what it replaces. The answer is nothing, and a thing that replaces nothing looks a lot like a thing you don’t need.

The better question is what I could not do before, and the answer to that is an actual list. Change a value at a breakpoint once instead of once per declaration. Scope one to a subtree without touching the markup. Give a component a default that somebody else can override. Let a person change a color and watch the page change with it.

Sass variables were a workaround for CSS not having variables, and I’d assumed these were the standardized version of that workaround. They aren’t. They’re a different feature that happens to answer the same complaint, and I’d have gotten here faster if they’d been called almost anything other than variables. Though realistically what got me here wasn’t the name or the spec or any of the posts I’d skimmed since March. It was one call where I looked slow in front of a client. I’d like to say I sat down afterward and thought it through properly, but what actually happened is that I got embarrassed and went looking for a way to not do that again.

Read similar posts
10 min

Native nesting and the ampersand

I moved one component off the preprocessor and the card came back as a column of unstyled text with the padding still on it, because every rule in that file whose selector started with an ampersand had quietly stopped being a rule.

9 min

I named it after the page

A landing page needed one block to sit closer to the heading above it, and the modifier I wrote to do that had the name of a page in it.