Solved: CSS Specificity And Shadow DOM Overrides In Angular 2.4.1
Yesterday, I was struggling to overcome a CSS specificity issue in Angular 2's simulated shadow DOM functionality. After walking away from the problem, however, I realized that the issue was not in the shadow DOM emulation itself but rather in how I was applying CSS to my Angular 2 application. I was mixing metaphors, so to speak, using both external stylesheets and simulated shadow DOM. These two concepts don't play together very well. And, by moving external stylesheets into the shadow DOM of my root component, CSS specificity problems disappear.
Run this demo in my JavaScript Demos project on GitHub.
To quickly recap the issue I experienced yesterday, I was trying to define a global override for a default style that a given Angular 2 component was providing for its host element. My override was defined in an external stylesheet while the component's default style was defined in its own shadow DOM styles. Due to the fact that Angular 2 scopes shadow DOM styles using attribute selectors, my global override had a lower CSS specificity than the shadow DOM styles:
info-box { ... } < [ _nghost-blam-1 ] { ... }
Since the Type selector (info-box) of my global override has a lower specificity than the Attribute selector ([_nghost-blam-1]) of the target component, my global override was never applied.
One possible way to fix this would be to add an Attribute selector to my global override. And, the fun thing about CSS is that this attribute selector doesn't have to be part of the info-box element - it just has be somewhere in the selector. Such as another component's simulated shadow DOM attribute selector.
By moving the global stylesheet into the shadow DOM of the root component, that's exactly what we get. When we move our global styles into the shadow DOM of the root component, all of our global style selectors are scoped to the attribute selector of the root component's shadow DOM. This essentially normalizes specificity across all shadow DOM emulations, allowing you to think, once again, about traditional CSS relationships.
To see this in action, let's look at the info-box component from yesterday's post. The code here hasn't changed at all:
// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { Component } from "@angular/core";
@Component({
selector: "info-box",
inputs: [ "avatarUrl", "name", "title" ],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
border: 1px solid #CCCCCC ;
border-radius: 4px 4px 4px 4px ;
box-sizing: border-box ;
display: table ;
margin: 0px 0px 0px 0px ; /* This is the property to be overridden. */
min-width: 100px ;
padding: 20px 27px 20px 27px ;
text-align: center ;
}
.avatar {
display: block ;
border-radius: 50% ;
height: 75px ;
margin: 0px auto 0px auto ;
width: 75px ;
}
.name {
font-size: 22px ;
line-height: 24px ;
margin: 18px 0px 5px 0px ;
}
.title {
color: #999999 ;
font-size: 16px ;
line-height: 18px ;
}
/* -- variations - host class targeting. -- */
:host( .mini ) {
margin: 10px 0px 10px 0px ;
}
:host( .mini ) .name,
:host( .mini ) .title {
display: none ;
}
/* -- variations - media query targeting. -- */
@media screen and ( max-width: 600px ) {
:host {
border: none ;
height: 75px ;
min-width: 75px ;
padding: 0px 0px 0px 0px ;
width: 75px ;
}
.name,
.title {
display: none ;
}
}
`
],
template:
`
<img [src]="avatarUrl" class="avatar" />
<div class="name">
{{ name }}
</div>
<div class="title">
{{ title }}
</div>
`
})
export class InfoBoxComponent {
public avatarUrl: string;
public name: string;
public title: string;
// I initialize the component.
constructor() {
this.avatarUrl = "";
this.name = "";
this.title = "";
}
}
In this particular exploration, we're going to be overriding the host element's "margin" property. Notice that margin is defined on both ":host" and on ":host(.mini)". We're going to be overriding both of those use-cases from our root component. And, since we're moving our global stylesheet into the shadow DOM of the root component, the root component is the only other piece of code that we need to look at:
// Import the core angular services.
import { Component } from "@angular/core";
@Component({
selector: "my-app",
styles: [
`
:host {
display: block ;
}
/*
By using the "deep" operator ( >>> or /deep/ ), we can provide override
styles for any component consumed within this application component tree.
Since we are deep-scoping this to the :host element, Angular will use an
attribute selector followed by the type selector:
--
[ _nghost-blam-1 ] info-box { ...overrides... }
--
... which will be able to override default host styles provided in the
info-box component itself since the info-box component will only be using
an attribute selector.
--
Attribute + Type > Attribute
--
As such, the CSS selector below will have a higher specificity.
*/
:host >>> info-box {
margin: 16px 0px 16px 0px ;
}
/*
We can also override a specific instance of the info-box host element.
Since this is an element inside the current component's shadow-DOM, it
will be given an attribute selector:
--
info-box.mini[ _ngcontent-blam-1 ] { ...overrides... }
--
... which will be able to override default host styles provided in the
info-box component itself since the info-box component will only be using
an attribute selector.
--
Type + Class + Attribute > Attribute
--
As such, the CSS selector below will have a higher specificity than both
the deep-scoping overrides above and the default info-box styles.
*/
info-box.mini {
margin: 8px 0px 8px 0px ;
}
`
],
template:
`
<info-box
avatarUrl="./sarah.png"
name="Sarah Connor"
title="Freedom Fighter">
</info-box>
<info-box
avatarUrl="./sarah.png"
name="Sarah Connor"
title="Freedom Fighter"
class="mini">
</info-box>
`
})
export class AppComponent {
// ...
}
In the first override, by using the "deep" selector (>>>), we're telling Angular not to scope the info-box token itself such that this CSS rule will be applied to any info-box element anywhere in the component tree. However, since this selector is still part of the root component's shadow DOM, Angular will prefix the selector with the root component's attribute selector:
Now, our global override selector contains both an Attribute selector and a Type selector, which becomes more specific than the Type selector in the info-box shadow DOM:
[ _nghost-dlt-0 ] info-box { ... } > [ _nghost-blam-1 ] { ... }
As such, when we look at the CSS that is being applied to the info-box instance on the page, we can see that our global style is finally overriding the default style of the info-box host element:
As you can see, the default "margin" style of the info-box component is being overridden. This is because the attribute selector in the root component's shadow DOM essentially cancels out the specificity of the attribute selector in the info-box component's shadow DOM. After this is normalized, the global override's Type selector gives the global override a higher specificity than the info-box component's host styles.
In this demo, I'm also targeting "info-box.mini" to demonstrate that we don't have to use the "deep" operator to leverage the normalization of specificity. And, if we look at the CSS applied to the second info-box instance, we can see that the non-deep targeting of info-box in the root component's template works just as we would expect it to:
After many years of using external CSS stylesheets, it's hard to start thinking about the root component of an Angular 2 application in terms of shadow DOM. But the reality is, your entire application is encapsulated within the shadow DOM of the root component. As such, it makes sense for the global styles of your application to be part of the root component's shadow DOM styles. Luckily, thinking this way normalizes CSS specificity across the component tree and makes it possible to override a component's host styles.
Want to use code from this post? Check out the license.
Reader Comments
Hey Ben, great article! FWIW, if one is using SASS with Angular (ie. with Angular CLI) using `>>>` does not work at present, therefore, one must still use `/deep/`.
Thanks for this, it cleared up some questions i had about how best to use a global style sheet. Nice and easy to understand as usual.
@All,
This morning I sanity-checked the behavior of "styleUrls" in the Angular component meta-data. Turns out, I had been making a very poor assumption - that shared styleUrls would create duplication in the compiled assets. This is, in fact, not true:
www.bennadel.com/blog/3372-sanity-check-shared-style-urls-are-only-compiled-into-angular-5-0-1-once.htm
... the shared styleUrl just gets compiled as a single module and the required into each consuming component (at least in the way I am compiling with Webpack). This is very exciting because it means that much of what would have been higher-up in the component tree can actually be moved down into various components where it becomes much more clear and locally-scoped from a view-encapsulation standpoint.