currentColor
Your text color is already the default for a handful of other properties, and icons can follow it for free until you hit one of the two places where it stops working.
The red I kept typing was #b3261e. I didn’t pick it and there’s nothing special about it. It was 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, and called it done. About a week later somebody sent me a screenshot of a delete button. Its trash icon was still the old red, sitting an inch from a label in the new one 😬
The icon’s fill was set in a template with the hex in caps, and my replace was case sensitive. Trivial, and my fault.
Afterward I went and counted, and that’s the part that stuck with me. The danger variant of that one button set its color 5 separate times:
.button--danger {
color: #b3261e;
border-color: #b3261e;
text-decoration-color: #b3261e;
box-shadow: 0 1px 0 #b3261e;
}
.button--danger .icon { fill: #b3261e; }
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.
If you don’t give border-color a value, it’s 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, which in practice is the text color. That’s why the caret in an input is the right color in every form you’ve ever built.
I had the focus ring backwards. I’d have told you it was drawn in the text color. It isn’t, though outline-color isn’t the reason.
On paper the default for outline-color went from invert in CSS2 to auto in the current draft. Browsers compute it to currentColor anyway, same as a border.
The ring you actually see comes from the browser’s own stylesheet, which gives a focused element outline-style: auto. And auto means the browser draws whatever ring it thinks will be visible. In Chrome and Firefox that’s a deliberately two-tone thing that shows up on any background.
That’s good behavior. It just isn’t your color. So a focus ring that follows the component has to set a style of its own, and then the color comes along for free.
And fill on an SVG is black. It’s not the text color and never was, which is why every codebase I’ve ever opened has a rule somewhere pointing at an icon.
So I’d guess somewhere between half and two thirds of the color declarations in a normal stylesheet are restating a default. That’s 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. I’d actually recommend going further and reaching for currentColor on purpose, so the whole component hangs off one property:
.button {
/* no color on either of these, so both follow `color` */
border: 1px solid;
box-shadow: 0 1px 0;
}
.button:focus-visible { outline: 2px solid; }
.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); }
Every state and every variant is one declaration. Hover doesn’t restate the border and disabled doesn’t restate the icon. Adding a fifth variant is one 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. 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 a post about taking a client’s site dark. In it I said 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 six 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.
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="…"/>
</svg>
An arrow inside a link is the link color. On hover it moves with the link, and in dark mode it moves with the theme. In a disabled button it goes gray, on an inverted panel it inverts, and in the danger variant it goes red. 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 handoff 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
It all stops working when an SVG is loaded through <img>, as a background-image, or through content: url(). In each of those the SVG is a separate document, and your page’s stylesheet doesn’t reach inside it. 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 looks like it should work.
There are three ways around it. Inline the SVG into the page, which is what I do most of the time.
Or put the icons in a sprite and point at them with <use>, which does inherit properly. The sprite can sit in the page or in its own file. A separate file has to come from the same origin as the page because <use> won’t fetch one from anywhere else.
Or use the mask trick:
.icon {
background-color: currentColor;
mask-image: url("/icons/arrow.svg");
mask-repeat: no-repeat;
mask-size: contain;
}
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. It can only ever be one color, though. For interface icons that’s usually fine, and for anything with two tones in it that’s the end of that idea.
One per customer
There are two more things worth knowing before you build 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. 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 from writing the token name five 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
color-mix() is the newer piece, and it takes currentColor like any other value. That’s what moved this from a tidiness thing to something I’ll actually design around:
.callout {
border: 1px solid color-mix(in oklab, currentColor 30%, transparent);
background-color: color-mix(in oklab, currentColor 8%, transparent);
}
A tinted panel whose border and background are both derived from its text color. Set color and you get three coordinated values out of it. Change color on a variant or a theme or a hover and all three move together. They can’t come apart because there’s only one number in the system.
I used to do this with SCSS color functions. But those resolved on my laptop at build time, and this resolves on the page. So 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 comes from CSS Color 3, which became a Recommendation in 2011. The behavior it names is older still, since 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 it’s not like I learned something new. I’d used it, written about it, and told other people to use it. I just never let it be the thing a component was built on. 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.