Web Components API: Shadow DOM and Light DOM
In our series of articles about web components, we have first made an introduction into the web components world and looked into how we got to where we are today. Next, we talked about the web components API, more specifically how to define them, their attributes, and props.
So far, we know the fundamentals and we're able to define a web component, but, how can we define the content of the component, the markup, and finally, the component's structure?
Our web component content will be the sum of a Shadow DOM and a Light DOM, using tags like <template>
and <slot>
.
Shadow DOM
Let's sum up, what we talked about Shadow DOM in the first article of the series:
The Shadow DOM API is probably the most important API of Web Components. This API brings us encapsulation for both markup and styles. This means that our web component code and styles will not overlap with the rest of the elements of the page where the component belongs to. The encapsulation applies both to the web component on the outside as well as the page inside the web component. For this to be possible an independent DOM subtree (shadow DOM) is attached to the main DOM.
To make things more clear:
- If we execute a
document.querySelector()
we won't find any element of the web component. - If we define some style for, let's say, a
<div class="button">
, and inside the web component there was also adiv
with the same class, it would not be affected by the outside styles.
Regarding to code, a web component with Shadow DOM could look in the following way:
<my-profile name="Marty" lastname="Mcfly">
#shadow-root
<div class="profile-picture">
<img src="marty.png" alt="Marty Mcfly" />
</div>
<div class="name">Marty Mcfly</div>
</my-profile>
We have seen what the ShadowDOM means and how it looks like, now let's try to implement it using the API.
We will add a shadow root to our shadow host (web component). To do that we need to execute the function attachShadow()
, which has the following syntax:
var shadowroot = element.attachShadow(shadowRootInit);
The shadowRootInit
parameter is a settings object with the following structure:
mode: A string specifying the encapsulation mode for the shadow DOM tree. This can be one of:
open
: Elements of the shadow root are accessible from JavaScript outside the root, for example using Element.shadowRoot:closed
: Denies access to the node(s) of a closed shadow root from JavaScript outside it. Not commonly used.
delegatesFocus: A boolean that, when set to true, specifies behavior that mitigates custom element issues around focusability. When a non-focusable part of the shadow DOM is clicked, the first focusable part is given focus, and the shadow host is given any available
:focus
styling
This function must be executed inside the context of our web component's class, specifically, inside the constructor call of our web component.
class MyCustomElement extends HTMLElement {
constructor() {
super();
// ...
this.shadowRoot = this.attachShadow({ mode: "open" });
// ...
}
// our custom element code
}
The Element.attachShadow()
method (in our code, this.attachShadow
as this
is pointing to our HTMLElement
class), attaches a shadow DOM tree to the specified element and returns a reference to its ShadowRoot. This reference to that ShadowRoot is what we are storing in this.shadowRoot
variable, in order to make some other operations later, as appending our web component content.
It's important to note that is not possible to attach a ShadowRoot to all existing elements. We can attach a shadow root to the following elements:
<article>
<aside>
<blockquote>
<body>
<div>
<footer>
<h1>
<h2>
<h3>
<h4>
<h5>
<h6>
<header>
<main>
<nav>
<p>
<section>
<span>
Then, once we have a ShadowRoot attached to our element, it's time to define its content. To do that, we will do using the HTML <template>
tag or a String variable with the HTML content. Let's see how it works.
Defining content using <template>
We can define the web component's content using the <template>
tag. The <template>
allows us to define some HTML content on the client-side that will not be rendered with the page load but will be available to be instantiated. That means, in the web component's context, that we can define our web component's content but this will not be rendered. However, we can instantiate from our web component's code to be attached to our ShadowRoot. This content will only be rendered when our web component will be instantiated.
As we said, we will define the web component's content in the HTML file and then we will have to get it from the web component's code file (a.k.a JS file).
Let's say we have an HTML file with our web component's as follows:
<HTML>
<head></head>
<body>
<!-- our web component's code -->
<template id="webComponentsHTML">
<div class="profile-picture">
<img src alt="Marty Mcfly" />
</div>
<div class="name"></div>
</template>
</body>
</HTML>
Then, from our web component's code, we will access and attach the web component's content to the ShadowRoot as follows:
// get the template code
const template = document.querySelector("#webComponentsHTML");
// Attaches a shadow DOM tree to our element (this)
this._shadowRoot = this.attachShadow({ mode: "open" });
// Set our elements code
this._shadowRoot.appendChild(template.content.cloneNode(true));
Maybe you're wondering why we're using the cloneNode()
method instead of using directly template.content
. The answer is because we are using the appendChild
method, which moves the appended node from its current position to the new position, so there is no requirement to remove the node from its parent node before appending it to some other node. In order to maintain the template content, we are using cloneNode()
.
Defining content using inside the JS file
The other way to define our web component's content is doing it, as we said, inside the JS file using the innerHTML
property of our ShadowRoot instance. We can define our HTML content directly to the innerHTML
property (first snippet) or pointing to another variable if needed (2nd snippet):
Directly to the innerHTML
property
// component's code
const componentCode = `
<div class="profile-picture">
<img src alt="Marty Mcfly" />
</div>
<div class="name"></div>
`
// Attaches a shadow DOM tree to our element (this)
this._shadowRoot = this.attachShadow({ mode: "open" });
// Set our elements code
this._shadowRoot.innerHTML = componentCode;
Pointing to another variable
// Attaches a shadow DOM tree to our element (this)
this._shadowRoot = this.attachShadow({ mode: "open" });
// Set our elements code
this._shadowRoot.innerHTML = `
<div class="profile-picture">
<img src alt="Marty Mcfly" />
</div>
<div class="name"></div>
`;
Both ways of defining the component's content accept HTML tags as well style tags in order to define the styles of the content.
Declarative ShadowDOM
There is a brand new way to define ShadowDOM: declarative ShadowDOM - implement and use Shadow DOM directly in HTML. As a new web platform API, Declarative Shadow DOM does not have widespread support across all browsers yet. Declarative Shadow DOM is available in Chrome 90 and Edge 91. It can also be enabled using the Experimental Web Platform Features flag in Chrome 85.
As it's not fully implemented acroos all browsers, we won't go further into this topic, but here are some resources if you want to learn more:
- Declarative Shadow DOM - web.dev
- WICG/webcomponents
- Declarative Shadow DOM - TPAC 2020 breakout session
Light DOM
You should already have a clear understanding of what ShadowDOM is, now it's time to talk about LightDOM. LightDOM is the other DOM tree along with ShadowDOM that defines the web component's content. While ShadowDOM points to the main content of the component and it's defined by the web component's developer, LightDOM points to content that is not mandatory and it's defined by the person who is consuming our web component.
Let's sum up, what we talked about Light DOM and, specifically slots, in the first article of the series:
Slots allow the author of a web component to define which content will be customizable by the consumer with his own HTML. Slots are the way we have to customize the content of our web component. As they are not part of Shadow DOM, and therefore, are not encapsulated, they are affected by page styles, and can be queried.
Let's see a use case in order to understand the concept better. Let's say we have a profile card (as we have been seeing in the previous articles) where we have a picture, a name, and a description. Both the name and the description could be set by using attributes, but attributes are limited to primitive types, so only String will be supported. Maybe, when defining our description we want to highlight some parts of it with bold text, using <b>
tag. We can do that by using a slot:
This would be the web component's definition:
<div class="profile-picture">
<img src alt="Marty Mcfly" />
</div>
<div class="name"></div>
<div class="description">
<slot name="description"></slot>
</div>
This would be the web component's usage:
<my-profile picture="src/myProfilePicture.jpg" name="Marty McFlY">
<div slot="description">
I am a <b>time traveller</b> who loves music and plays the electric guitar.
</div>
</my-profile>
This is not totally true. Attributes can receive complex types as arrays or objects by setting them from JavaScript, but this is not a good practice and it doesn't follow the standards.
The use case described above is a very simple example just to illustrate the slot concept and understand it better. Another case could be, for instance, a tabs component, where we would define the tab content using slot, as the tab content is unpredictable and could have all kind of content (any kind of HTML, remote content fetched from a server... anything).
Default and named slots
A web component can have as many slots as needed, so we need a way to differentiate them. To do it we can name it, using the name
attribute in the slot definition and the slot
attribute in the web component usage. On another hand, we can define a default slot as well. The default slot will be the one that has not defined the slot
attribute.
To understand it better, let's go back to the last example.
This would be the web component's definition:
<div class="profile-picture">
<slot><slot>
</div>
<div class="name">
<slot name="name"></slot>
</div>
<div class="description">
<slot name="description"></slot>
</div>
This would be the web component's usage:
<my-profile>
<img src="src/myProfilePicture.jpg" />
<div slot="name">
<h1>Marty McFly</h1>
<h2>Also known as Levis Strauss</h2>
</div>
<div slot="description">
I am a <b>time traveller</b> who loves music and plays the electric guitar.
</div>
</my-profile>
As we can see, we have two DIVs elements pointing to two slots, but we have a picture (<img>
), that doesn't have any slot set, so this one will be pointed as the default slot, the one which has no name in the web component's definition.
Styling WebComponents (ShadowDOM and LightDOM)
ShadowDom brings us encapsulation for markup as well as for styles, but as web component's author, we can set some ways to customize the web component's appearance from outside.
On the other hand, as mentioned before since LightDOM is not encapsulated, it's affected by page styles and can be queried. However, we, as web component's authors, can define some default styles that will be applied to slots.
In the next articles we will explore this deeper, meanwhile, we can list the ways we have to do it:
ShadowDOM
::part()
pseudo-element:host-context
selector:host
selector:host
selector and classes- CSS vars
LightDOM
::slotted()
pseudo-selector
Conclusion
Step by step, we are exanding our Web Components knowledge. We are getting really close to being ready to write our first web component from scratch, so make sure to keep an eye on this series!
Also, ddon't forget to read the first two articles of the series:
Thanks for reading!
Originally written by Rafa Romero Dios for JavaScript Works