← All writing
Craft · · 9 min

Custom properties for color

On the properties that already default to your text color, the icons that follow it for free, and the two places it stops working.

CSS

was #b3261e. Nothing special about it, not a color I picked, just the red on one client’s delete and error states, and it turned up in their stylesheet about 40 times.

Then the brand moved to a slightly different red, which happens. I ran the replace, clicked through the buttons and the toasts, called it done. About a week later somebody sent me a screenshot of a delete button whose trash icon was still the old red, sitting an inch from a label in the new one 😬

The icon’s fill lived in a template, written in caps, and my replace was case sensitive. Trivial, and my fault.

Going and counting afterward actually stuck with me. The danger variant of that one button set its color 5 separate times:

button.css, before
.button--danger {
  color: #b3261e;
  border-color: #b3261e;
  text-decoration-color: #b3261e;
  box-shadow: 0 1px 0 #b3261e;
}
.button--danger .icon { fill: #b3261e; }
CSS

Times 4 variants, plus hover and disabled on each. And 3 of those 5 declarations were restating a value I’d have gotten for free by leaving them out.

I was arguing with the defaults

This is the part I’d never actually gone and checked, in a decade of writing CSS for a living.

border-color, if you don’t give it one, is the element’s color. So border: 1px solid with no color in it is a border in the text color, and always has been. text-decoration-color is the same, which is why underlines match their text without anybody asking. So are column-rule-color and text-emphasis-color. A box-shadow with no color in the value uses the current color too. caret-color defaults to auto, and auto in practice is the text color, which is why the caret in an input is the right color in every form you’ve ever built.

The one I had wrong, and would have argued about: outline-color is not in that list. I’d have told you it defaults to the text color. It doesn’t. The old CSS2 default was invert, the current one is auto, and auto means the browser draws whatever focus ring it thinks will be visible, which in Chrome and Firefox is a deliberately two-tone thing that shows up on any background. That’s good behavior. It isn’t your color, so a focus ring that follows the component has to say outline-color: currentColor out loud.

And fill on an SVG is black. Not the text color, never was. Which is the reason every codebase I have ever opened has a rule somewhere pointing at an icon.

So somewhere between half and two thirds of the color declarations in a normal stylesheet are restating a default. Not wrong. Just 40 places a value can drift out of step instead of 4.

Set one, get the rest

Leaning on the defaults is one thing. Reaching for currentColor deliberately, so the whole component hangs off one property, is the version I’d actually recommend:

button.css, after
.button {
  /* no color on either of these, so both follow `color` */
  border: 1px solid;
  box-shadow: 0 1px 0;
  outline-color: currentColor;
}
.button .icon { fill: currentColor; }

.button:hover    { color: var(--accent-strong); }
.button:disabled { color: var(--text-disabled); }

.button--danger  { color: var(--danger); }
.button--quiet   { color: var(--text-muted); }
CSS

Every state and every variant is 1 declaration. Hover doesn’t restate the border, disabled doesn’t restate the icon, and a fifth variant is a line.

More than the line count, I like that the thing then behaves correctly in contexts I didn’t think about. Drop it inside a panel that sets its own text color and the entire component comes along, because color inherits and there was never a hardcoded value in there to fight it.

Icons are the whole reason I care

Back in 2019 I wrote, in a post about taking a client’s site dark, that an inline SVG using currentColor for its fills just works, and that I’d started asking for icons that way as a matter of course. Then I spent 6 more years writing .button--danger .icon { fill: … } by hand on every project anyway.

Knowing a keyword and organizing a component around it turn out to be different skills. I only picked up the second one by making the mistake at the top of this post.

icon.svg
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
  <path fill="currentColor" d="…"/>
</svg>
HTML

An arrow inside a link is the link color. On hover it moves with the link. In dark mode it moves with the theme. In a disabled button it goes gray, on an inverted panel it inverts, in the danger variant it goes red, and not one of those is a rule somebody had to write or a rule somebody can forget.

I ask for icons as SVG with no fill at all now, or with currentColor. I’d put it in a design handover checklist if I had one of those. The whole ask is “please don’t paint them,” which is an easy thing to get agreement on and a very easy thing for a busy person to forget. So it’s worth saying every time.

The document next door

Where it stops: an SVG loaded through <img>, or as a background-image, or through content: url(). That’s a separate document, your page’s stylesheet doesn’t reach inside it, and currentColor in there resolves against that document’s own root, which is black. Everybody hits this exactly once and it’s genuinely confusing for about 10 minutes, because the markup is right there and it looks like it should work.

Three ways around it. Inline the SVG into the page, which is what I do most of the time. Or inline a sprite once and point at it with <use>, which does inherit properly, though be warned that an external file referenced with <use> doesn’t work in Chrome or Safari at all, only Firefox, so the sprite has to be in the document. Or use the mask trick:

icon.css
.icon {
  background-color: currentColor;
  mask-image: url("/icons/arrow.svg");
  mask-repeat: no-repeat;
  mask-size: contain;
}
CSS

The SVG becomes a stencil and the color comes from the background. So you get an icon from an external file that follows the text color. The catch is that it can only ever be one color, which for interface icons is usually fine and for anything with two tones in it is the end of that idea.

One color, and it’s the element’s own

Two more things worth knowing before building anything around this.

You get one color per element, and that’s the whole budget. Text and border and icon can all follow it because they’re all meant to be the same color. A component whose background is one value and whose text is another still needs a second value from somewhere, and that somewhere is a custom property, which is the argument I was making about those back in 2016. currentColor doesn’t replace tokens. It stops you writing the token name 5 times.

And it’s the element’s own color, not its parent’s. currentColor on a child resolves against the child’s computed color, so if something in between set a color you’d forgotten about, that’s the one you get. Nearly always what you want, occasionally 20 minutes in DevTools.

Mixing from what’s already there

The newer piece, and the one that moved this from a tidiness thing to something I’ll actually design around, is that color-mix() takes currentColor like any other value:

callout.css
.callout {
  border: 1px solid color-mix(in oklab, currentColor 30%, transparent);
  background-color: color-mix(in oklab, currentColor 8%, transparent);
}
CSS

A tinted panel whose border and background are both derived from its text color. Set color and you get 3 coordinated values out of it. Change color, on a variant or a theme or a hover, and all 3 move together, and they can’t come apart because there’s only one number in the system.

That’s the thing I used to do with SCSS color functions, except those resolved on my laptop at build time and this resolves on the page, which means it survives a theme toggle, a light-dark() pair, and whatever color a client picks in an admin panel at 6 pm.

Older than flexbox

The component ended up going from about 40 color declarations to 4.

None of this is new. I keep turning that over. The keyword is CSS Color 3, which went Recommendation in 2011, and the behavior it names is older still, because CSS 2.1 already said a border with no color of its own takes the element’s color. It’s older than flexbox, older than custom properties, older than my career.

So I didn’t learn something. I’d used it, I’d written about it, I’d told other people to use it. I just never let it be the thing a component was built on. So it stayed a trick I knew instead of a decision I’d made, right up until a case-sensitive find and replace made the point for me in front of a client.

Read similar posts
2 min

light-dark()

Two color values in one declaration, picked by the color scheme, which removes most of the reason a theme needed a second block of custom properties at all.

9 min

Style queries

A card with 5 surface variants and 4 children that each had to know which one they were sitting in, and 20 selectors across 4 files holding that together until a feature I'd been ignoring since 2024 deleted all of them.