Styling Web Components
Sep 28, 2018 • 13 Minute Read
Shadow DOM
The Document Object Model (DOM) is an object-oriented representation of the structure, styles, and content of our HTML. It exposes the document as nodes and objects, in a tree-like structure, that programming languages such as JavaScript can manipulate. The Shadow DOM allows for hidden DOM trees to be added to the document DOM tree. These hidden DOM trees are isolated from the parent DOM tree, confining the scope of the CSS to the web component, allowing repeated classes and IDs found in the parent without interaction. This is how styling encapsulation and web component creation are achieved.
When using ShadowDom encapsulation in Angular, the application uses the browser's native implementation. When using Emulated it simulates the native functionality of the browser to overcome the currently limited support, but similar styling principles and rules are applied. (For more details about types of encapsulation in Angular see CSS Encapsulation in Angular)
Let's take a closer look at how it works.
Custom Components Using Vanilla JavaScript
Say we want to create a chip component we can reuse anywhere in our code without the help of Angular:
A limited number of browsers currently support shadow DOM, used Chrome v. 68 in demo.
HTML
<html>
<head>
<meta charset="utf-8">
<title>Vanilla JS</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<h1>Vanilla JS</h1>
<section>
</section>
<script src="main.js"></script>
</body>
</html>
CSS
body {
font-family: sans-serif;
font-size: 16px;
padding: 1rem;
}
section {
background: ghostwhite;
border: solid 1px lightgray;
padding: 1rem;
}
Our page should look something like this:
Now for creating the custom component. Our JS file is currently blank, so let's get started. The first thing we need to do is define a class that extends HTMLElement and defines our element. This is our shadow host which will contain our shadow root and shadow tree.
JS
class CustomChip extends HTMLElement {
constructor() {
super();
// Define web component here
}
}
// customElements.define(name, constructor, options);
customElements.define('custom-chip', CustomChip);
Note that the custom element name must contain a hyphen.
In our HTML:
HTML (Partial)
<section>
<custom-chip></custom-chip>
</section>
We will define the web component inside the constructor. The first thing we are going to do is to attach a shadow root to our shadow host.
JS (Partial)
class CustomChip extends HTMLElement {
constructor() {
super();
// Create a shadow root
const shadow = this.attachShadow({ mode: 'open' });
}
}
We use a mode of open because it allows us to access the shadow DOM using JavaScript. If we used closed, myCustomElem.shadowRoot would return null, and we would not be able to access it from outside of the shadow DOM.
As we can see in the dev tools, we now have an instance of our newly created component with a shadow DOM.
Because we are creating a chip, let's add a label and an avatar. While still inside of the constructor, we create HTML elements and then attach them to the shadow DOM. These elements make up the shadow tree.
JS (Partial)
// Create container
const wrapper = document.createElement('div');
// Add avatar
const avatar = document.createElement('img');
let imgUrl;
if (this.hasAttribute('img')) {
imgUrl = this.getAttribute('img');
} else {
imgUrl = 'assets/default.png';
}
avatar.src = imgUrl;
// Add text
const info = document.createElement('span');
info.setAttribute('class', 'info');
// Take attribute content and put it inside the info span
const text = this.getAttribute('label');
info.textContent = text;
// attach the created elements to the shadow dom
shadow.appendChild(wrapper);
wrapper.appendChild(avatar);
wrapper.appendChild(info);
Because we are pulling the text for our chip from the label attribute, let's go ahead and add one to our HTML. Same for the image path.
HTML (Partial)
<section>
<custom-chip label="Foo" img="assets/purple.png"></custom-chip>
<custom-chip label="Bar"></custom-chip>
</section>
We now have an avatar and some text inside of a container, so let's style it. Still, in the constructor, we add the styles and then attach them to the shadow DOM.
JS (Partial)
// Style the component
const style = document.createElement('style');
console.log(style.isConnected);
style.textContent =
`
:host {
background: lightgray;
border-radius: 40px;
display: inline-block;
font-family: sans-serif;
padding: .5rem 1rem;
}
img {
height: 1rem;
margin-right: .5rem;
vertical-align: baseline;
}
`
// attach styles to shadow DOM
shadow.appendChild(style);
console.log(style.isConnected);
Our newly created component is scoped, styled, and ready to use.
Angular Emulated DOM
Angular works in a similar way. If we create a CustomChip component and style it the same, we get similar results. But here Angular does a lot of the heavy lifting for us. Let's take a look.
We will be using Angular 6 with Emulated Encapsulation (the default encapsulation method for Angular).
Using the CLI we will generate an app and a CustomChip component.
$ ng new angular
$ cd angular
$ ng generate component CustomChip
We then add in all the same CSS and HTML and create the Typescript to pass in a label and an image source.
app.html
<h1>Angular</h1>
<!-- Same as the vanilla version -->
<section>
<custom-chip label="foo" img="/assets/purple.png"></custom-chip>
<custom-chip label="bar"></custom-chip>
</section>
styles.css
body {
font-family: sans-serif;
font-size: 16px;
padding: 1rem;
}
section {
background: ghostwhite;
border: solid 1px lightgray;
padding: 1rem;
}
custom-chip.html
<div>
<img [src]="img ? img : 'assets/default.png'">
<span class="info">{{ label }}</span>
</div>
custom-chip.css
/* Same as the vanilla version */
:host {
background: lightgray;
border-radius: 40px;
display: inline-block;
font-family: sans-serif;
padding: .5rem 1rem;
}
img {
height: 1rem;
margin-right: .5rem;
vertical-align: baseline;
}
custom-chip.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'custom-chip',
templateUrl: './custom-chip.component.html',
styleUrls: ['./custom-chip.component.css']
})
export class CustomChipComponent {
@Input() label;
@Input() img;
}
We now have the same component the Angular way.
Styling
The advantage of having the CSS for the isolated component is that if we add an image elsewhere on the page, it will not be affected by the styles given to the avatar in the component. Let's add another chip of similar construction but in plain HTML this time.
HTML (Partial)
<section>
<custom-chip label="foo" img="/assets/purple.png"></custom-chip>
<custom-chip label="bar"></custom-chip>
</section>
<div>
<img src="assets/default.png">
<span>I am also a chip</span>
</div>
Our new element is completely unaffected by the component styles. The component styles are confined to the component.
Styling from Outside the Component Using styles.css
To edit the styles of a component from outside of the component itself in Angular, we can include the style in the styles.css file at the root of the project. Styles placed here will apply to the application globally, and affect all elements, even those inside of components. This is a great solution to allow components to inherit base styles such as font-family.
This differs from our Vanilla JS version, in which our component will not inherit the styles as our component is isolated from the styles assigned to the document DOM.
styles.css (partial)
img {
background: palegoldenrod;
border: solid 1px gray;
border-radius: 50%;
display: inline-block;
margin-top: 12px;
padding: 2px 10px;
}
div {
background: yellow;
border-radius: 0;
margin-top: 1rem;
padding: 1rem;
}
:host
If we want to make an exception and edit the chip's styles in one particular component, we can try to put the CSS in that component's CSS file.
We now get similar behavior for both examples. In Angular, the new styles we added are scoped to appComponent and, even though CustomChip is a child of AppComponent, CustomChip does not inherit from its parent the same way that the shadow DOM does not inherit styles from the document DOM.
So how do we tap into the shadow DOM or child component's styles from the parent? Notice when we wrote the original CSS for the component, instead of adding the background color to the container div, we used :host.
CSS (partial)
:host {
background: lightgray;
border-radius: 40px;
display: inline-block;
font-family: sans-serif;
padding: .5rem 1rem;
}
Both in app.component.css for angular and in styles.css for Vanilla JS, if we change div to custom-chip, we will see that the component now inherits the styles.
Putting styles on the host itself is an easy way to edit the container's styles.
:host-context
Conditional formating can be passed into the component through the host as well. A component can be set up to look at the context in which it is used and format itself accordingly. In the component's CSS, if we add:
CSS (Partial)
:host-context(section) {
background: gray;
color: white;
}
when the component is found in a section, it will have a gray background.
Notice how inheritance in this case works a little bit differently in each of the examples. In vanilla JS the styles set on the host will always take priority. In Angular however, styles set in the component itself take priority.
/deep/, :ng-deep, and >>>
The shadow piercing combinators (/deep/, ::ng-deep, and >>>) allow for elements which are part of the shadow tree to be styled from the outside of the component. In our example, we could use one of these to edit the styles on our avatar. This technique however, is being removed from major browsers and is deprecated in Angular.
Slots and ng-content
Instead of being able to unconditionally pierce through the shadow DOM, we must alter the construction of our component so that it will specifically allow elements to be injected. The architecture must allow areas in which elements from the parents can be inserted. Because these elements are created outside of the component, they can be styled externally.
In vanilla JS, our component needs to be slotted. Slots are placeholders inside of the component that can be filled with externally styled markup passed in from the parent component, and therefore scoped to the parent component.
main.js (Partial)
const slot = document.createElement('slot');
slot.setAttribute('name', 'slottedImage');
// attach the created elements to the shadow dom
shadow.appendChild(wrapper);
wrapper.appendChild(slot);
wrapper.appendChild(avatar);
wrapper.appendChild(info);
In Angular, we can use ng-content to achieve the same effect.
custom-chip.component.html
<div>
<ng-content></ng-content>
<img [src]="img ? img : 'assets/default.png'">
<span class="info">{{ label }}</span>
</div>
Whether Vanilla JS or Angular, our parent HTML is the same. The content is inserted as a child of the custom component.
HTML (Partial)
<section>
<custom-chip label="Foo" img="assets/purple.png"></custom-chip>
<custom-chip label="Bar"></custom-chip>
<custom-chip label="Slotted">
<!-- Angular does not require the slot attribute -->
<img slot="slottedImage" src="assets/purple.png">
</custom-chip>
</section>
Our newly inserted image has taken on the styling assigned to it by the parent. In Angular, notice how the automatically generated _ngcontent attributes for the image inserted via ng-content takes on the attribute of the parent rather than of the component itself, explicitly demonstrating that the slotted image is scoped to AppComponent instead of ChipComponent.
Closing Remarks
In this article we have looked at how components are created, how the shadow DOM works, and the ins and outs of styling components. The code used in this guide can be found on github, feel free to play with it and experiment for yourself. Happy Coding!