I found out by accident, the way you usually find out. I was grepping the monorepo for a class name during an unrelated cleanup and turned up a second button component — same variant names, same markup shape, same focus-ring code down to the pixel offset, sitting in a feature team's folder under a slightly different name. Someone had copied our button, changed two color declarations, and shipped it eight months earlier.
Nobody had done anything wrong. That's what stuck with me. Their design called for a different accent on a marketing surface, the shared button had no way to express that, and they had a date. Copying the file was the fastest path from where they were standing to where they needed to be, and I'd have probably done the same thing under the same deadline.
The problem was what had happened in the eight months since. We'd fixed a focus-visible bug in the original. We'd bumped the disabled-state contrast to clear our accessibility target. We'd changed the border radius when the design language shifted. None of it reached the copy, because the copy wasn't a consumer of anything — it was a snapshot. Two buttons that looked identical in the design review and were slowly becoming different products.
A fork is a fossil. It preserves the day it was taken.
The theming surface exists whether or not you declared it
Here's the part I had wrong for years. I thought a component's public API was its inputs, and that its styles were internals. So the button's API was variant, size, disabled, and the SCSS was my business.
But consumers don't experience it that way. If a team needs to change how your component looks and your inputs don't allow it, they don't conclude that the component is finished. They conclude the API is missing, and they go around it — ::ng-deep, a specificity fight in a global stylesheet, or a copy of the file. Every one of those is a theming API. It's just one you didn't design, didn't document, and can't refactor without breaking someone.
CSS custom properties let you make that surface deliberate, and they work for this because of one specific behavior: a property declared with the double-dash syntax always inherits. That's not incidental. It means a value set anywhere above your component in the tree flows down into it, which is precisely what "let the consumer theme this from their own CSS" requires. It also means the value crosses Angular's view encapsulation, because emulated encapsulation scopes styles by rewriting selectors — it doesn't intercept inherited property values. The token reaches inside the component; the consumer's selectors still can't. That asymmetry is the whole feature: an opening you control, in a wall that still stands.
So the button declares what it will honor, with the fallback carrying the default:
.foo-button {
background: var(--foo-button-bg, var(--foo-color-brand));
color: var(--foo-button-fg, var(--foo-color-on-brand));
border-radius: var(--foo-button-radius, 0.5rem);
}
The consumer overrides it from their own stylesheet, scoped however they like — no fork, no piercing selector, no import of our internals:
.marketing-hero {
--foo-button-bg: #0b5d3b;
--foo-button-fg: #ffffff;
}
And the markup stays exactly what it was, which is the point — nothing about the call site changed:
<section class="marketing-hero">
<button foo-button variant="primary">Request a demo</button>
</section>
The rendered button inside that section resolves --foo-button-bg to the green, while every other button in the app still resolves it to the brand default through the fallback. One component, two appearances, no second copy to maintain. When we later fixed that focus-visible bug, the marketing team got the fix, because they were consuming the component instead of remembering it.
Two things I'd flag before anyone treats inheritance as a guarantee. If you register a token with @property to get type checking and a real initial value, the inherits descriptor defaults matter — set inherits: false and you've switched off the exact mechanism the contract depends on. It's a reasonable thing to want for an internal, non-inheriting variable, and a quiet way to break a public token. And a token API hands consumers the ability to set a color pair that doesn't meet contrast. Ours is a 7:1 target, and a green a marketing designer likes is not automatically a green that clears it against white. So publish --foo-button-bg and --foo-button-fg as a pair, document them as a pair, and keep a contrast check in the pipeline. That's part of the contract, not a nice-to-have. An API that lets people theme is also an API that lets people make an inaccessible button.
The three things teams do instead, and why they cost more here
An input for every styleable property is the option that feels most like Angular, and it's the one I'd argue against hardest. It reads well for the first two — color, maybe size — and then someone needs the hover background, the disabled border, the icon tint, and the focus-ring offset, and you're maintaining a parallel styling language in TypeScript. Each one has to be bound to a style in the template, each one becomes a public API you can't remove, and none of them can express "all buttons inside this region" without being threaded through every component in between. Inputs are the right tool for behavior and content. Using them for appearance means re-implementing the cascade by hand, in a language that doesn't have one.
Piercing selectors — ::ng-deep and friends — deserve a fair hearing, because they're what most large Angular codebases actually contain. They work today, and they're the only real option when you need to restyle a third-party component that published no tokens at all. But the Angular team strongly discourages new use of ::ng-deep — it survives for backwards compatibility, not as a recommendation. The deeper issue is that it makes every internal class name of your component part of its public surface by accident. Rename a private wrapper class in a patch release and you break a consumer who was never supposed to know it existed. That's the same failure as the fork, arriving through a different door: the consumer ends up coupled to your internals either way.
A runtime theme service — a signal holding the palette, styles bound in templates — is the option I've seen chosen most often for genuinely good reasons, and it's the closest call. It's legitimately better when the theme is data: user-selected accent colors, white-label palettes that arrive from an API, anything you can't enumerate at build time. The cost shows up when the theme isn't data. Styling now waits on JavaScript, so there's a paint before hydration where the default shows through. Components that only wanted to be looked at now hold a subscription. For a fixed set of brand surfaces, that's a lot of machinery to move a color. The two also compose better than they compete: let the service set custom properties on a root element, and it becomes the delivery mechanism for a token contract rather than a replacement for one.
Custom properties are the recommendation because they're the platform's own answer to this problem. They ride the cascade, they inherit, they need no framework and no script, and they fall back to your default when a consumer sets nothing. The tradeoff to accept honestly is that you're publishing names. Once --foo-button-bg is in someone's stylesheet, renaming it is a breaking change, so a token contract wants the same review discipline as any other public API. That's a real cost. It's a much smaller one than eight months of silent divergence.
Publish the Contract or Watch People Invent One
Every component already has a theming API. The only question is whether you wrote it or whether your consumers assembled it out of piercing selectors and copied files while you weren't looking. The second version has every cost of a public API and none of the benefits. It's undocumented, unversioned, and unsafe to change, because you don't know who depends on what. Choosing a handful of custom properties is less about CSS than about deciding, on purpose, which parts of a component are allowed to vary and saying so out loud. Name the tokens, document them in pairs where contrast depends on it, treat renames as breaking changes — and the next team that needs a different green files against your contract instead of taking a snapshot of your file.


