The button worked in the storybook. It worked in the unit test. Then it landed on a real page in the dashboard and rendered as the saddest thing a front end can produce: a bare, gray, browser-default button, the kind you get from <button> and nothing else. Every variant. Primary, danger, ghost — all of them, stripped back to the user agent's idea of a button.
Nothing had changed in the styles. That was the unsettling part. The SCSS was right there, the class names matched, and in isolation the component looked exactly like the design. Put it inside another component and the styling evaporated.
I had built the button the way I like to build them — as an attribute on a native element. You write <button foo-button variant="primary">Save</button>, not <foo-button>, because the real <button> brings its semantics, its keyboard behavior, and its form participation for free. The component's only job is to dress a button that already knows how to be a button. I still think that's the right call. It was also the source of the bug.
A host element is not the same element your styles are scoped to
Angular's default encapsulation is Emulated, and it earns its keep. It scopes a component's styles so they can't leak out and the rest of the app can't leak in. The way it does this is mechanical: at build time, Angular rewrites your selectors to carry a unique attribute, and stamps that same attribute onto the elements inside the component's template. A rule you wrote as .foo-button--primary becomes, roughly, .foo-button--primary[_ngcontent-abc], and every element the component renders gets _ngcontent-abc so the rule can find it.
Here's the seam I didn't have. On an attribute-host component, the styled element is the host — the <button> the consumer wrote, not anything in my template. And the host doesn't get the content attribute. It gets a different one, _nghost-abc. So the browser was handed a rule and an element that described two different things. This is what Emulated compiled my rule into:
.foo-button--primary[_ngcontent-abc] {
background: var(--foo-color-brand);
}
And this is the host button it was meant to style:
<button foo-button class="foo-button--primary" _nghost-abc>Save</button>
The selector wants _ngcontent-abc; the element only has _nghost-abc. No match, no background, no padding — a gray button. The rule was real. The class was real. They just lived on elements that never met.
In isolation it worked for an embarrassingly boring reason: the storybook wrapper happened to put the button where a matching attribute existed. The moment the button sat inside a component with a different scope id, the match was gone. The styling wasn't fragile. It was conditional on an accident.
That is the kind of bug that makes you trust your eyes less, which is exactly when you should reach for how the thing actually works.
The fix was a decision, not a patch
The reflex is to chase it with CSS — wrap the modifiers in :host(), which does compile against the host attribute and would have matched. That works for one component. It also quietly commits every future component in the library to remembering that its modifiers must be written host-relative, forever, or they break in a way that passes every test in isolation. I didn't want a design system whose correctness depended on everyone recalling a footnote.
So I made the boundary explicit instead. For these attribute-host primitives, I set the encapsulation to None and moved the scoping responsibility from the framework to a disciplined class namespace — every selector lives under a foo- prefix, which becomes the real wall between this library and the app.
@Component({
selector: 'button[foo-button]',
encapsulation: ViewEncapsulation.None, // styles target the host <button> directly
host: {
'[class.foo-button--primary]': 'variant() === "primary"',
'[class.foo-button--danger]': 'variant() === "danger"',
},
styleUrl: './button.scss', // every rule namespaced: .foo-button, .foo-button--primary, …
})
export class Button {
readonly variant = input<'primary' | 'danger' | 'ghost'>('primary');
}
Here it is in use — the consumer writes the attribute and a variant:
<button foo-button variant="primary">Save</button>
Which renders this host element, now carrying the modifier class and no scope attribute on the rule:
<button foo-button class="foo-button--primary">Save</button>
And the rule that styles it is simply this — no _ngcontent gate to miss:
.foo-button--primary {
background: var(--foo-color-brand);
}
It finds the class on the host, and the button is itself again: brand background, real padding, the focus ring. Same markup that rendered gray five minutes earlier.
You can see it running — every variant, with the exact encapsulation: None source open beside it — on the live design-system demo.
None gets a bad reputation because people reach for it to escape encapsulation they don't understand, and then their styles leak everywhere. That's a real risk, and the namespace is what answers it. The discipline I lost from the framework I got back from a naming convention I control — and a naming convention can't land its attribute on the wrong element.
What I'd undervalued until this is that for a component sitting on a native element, the encapsulation mode and the class namespace are not two separate choices. They're the same choice wearing two hats. Pick Emulated and you've signed up to think host-relative in every stylesheet. Pick None and you've signed up to own your namespace. Picking neither — inheriting the default and writing plain class selectors — is how you ship a gray button.
Encapsulation Is a Decision, Not a Default
The fix changed one line of config, but the lesson was the line of thinking behind it. A design system is the place where decisions get made once so nobody downstream has to make them again — and encapsulation is one of those decisions, not a setting you inherit and forget. For a component that lives on a native element, the boundary that scopes your styles and the namespace that names them are the same call. Make it on purpose, and the whole library stops having a category of bug. Leave it to the default, and the browser will make the call for you — usually at the worst possible moment, in production, in gray.


