Introduction to Web Components — Part II Shadow Dom
The first part of Web Components overview is about the Custom Elements standard.
Shadow DOM
The Shadow DOM
specifications bring a scope concept to Web styles’ definitions. Those scopes are implemented through multiple DOM
trees, embedded into a single document. In some way, the Shadow DOM
's approach can be compared with the iframe
element inside a document, as it has a naturally-isolated DOM
structure. In terms of documents, the Shadow DOM
's definitions are spread between several different standards, like DOM specification
, HTML specification
, CSS Scoping Module
, UI Events
, etc.
The DOM specification
defines a model for node trees and how nodes communicate with each other through events. The DOM
node tree is a hierarchical structure of nodes, where some nodes can branch out, while others are just leaves. The specification differentiates Document Tree
and Shadow Tree
via the connection to their roots. The document
object is the root of the Document Tree
, while for the Shadow Tree
a special type of root is proposed which is called the Shadow Root
. The Shadow Root
must be attached to any other tree under the host
element. The Shadow Root
can be thought of as a Document Fragment
- the specification defines a belonging relationship between two interfaces.
<script>
class CustomElement extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.shadowRoot.innerHTML = '<a href="#">My link</a>'
}
}
customElements.define('hello-shadow-dom', CustomElement)
</script>
<hello-shadow-dom></hello-shadow-dom>
The only way to create the Shadow DOM
is to call the attachShadow()
method of the Element
class. It creates a shadow root for the element. Only one shadow root can be attached for the element. The mode
property specified on the attachShadow()
call defines whether a tree is accessible from JavaScript
or not. The alternative to the 'open' value would be the 'closed' value. The attached shadowRoot
property is a parent of the specified node tree. The shadowRoot.host
is set up to the hello-shadow-dom
element in the following example.
<hello-shadow-dom></hello-shadow-dom>
#shadow-root (open)
<a href="#">My link</a>
The Slot
feature is widely used in UI Libraries
. In Angular,
it is called projection or transclusion and it is supported via the ng-content
directive. Vue
seems to be more compliant with standards - it calls the same functionality as the slot
. Finally, in React
that ability is even more germane to the library and can be achieved by {props.children}
usage inside the element container. The Slot
feature serves a placeholder purpose. On one hand, the Shadow Tree
may contain slots
elements defined with the slot
tag. When a developer wants to use the element on the other hand, he or she can specify a markup for declared slots
so the content will be rendered inside the Shadow Tree
wrapper.
<script>
class OhMySlotElement extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.shadowRoot.innerHTML = `<p>
<slot name="my-text">default</slot>
</p>`
}
}
customElements.define('oh-my-slot', OhMySlotElement);
</script>
<oh-my-slot>
<pre slot="my-text">
anything
</pre>
</oh-my-slot>
The final DOM
tree contains both a Shadow Root
with a p
child and the developer's pre
element. The latter is linked to the slot
node and replaces it's "default" content. The name attribute on the slot
tag defines which exact placeholder to use. It is possible to use an unnamed slot as well if there are no available alternatives.
The Shadow DOM
specification allows a developer to declare isolated styles and provides an API to use the main document context. Elements inside the Shadow Tree
are not accessible for the outer document CSS
selectors.
In the following example two styles’ sections are present — one is inside the main document, the other is inside the element’s attached Shadow DOM
.
<style>h3 { color: blue; }</style>
<script>
class CustomElement extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>h3 { color: red; }</style>
<h3>Shadow DOM</h3>
`
}
}
customElements.define('my-element', CustomElement);
</script><h3>Normal DOM</h3>
<my-element></my-element>
The style applied for a main document’s h3
element has a blue color, meanwhile h3
inside my-element
is painted red. It's notable that the main document can define styles for a host
element itself. When the my-element
style is set to have the green color, it will be applied to it's children, as in a normal style inheritance situation. The inherited color still has less priority than any other defined style.
The :host
pseudoclass selector is looking into the Shadow Root
's host
element.
:host { color: green; }
The styles in the main document have a major priority in comparison with :host
styles inside the element. It is possible to use the :host()
function for conditional selectors
:host(.error) { color: red; }
When the host
has the error
class attached, the red color will be applied to it.
Another functional selector is :host-context()
that can detect if there is an ancestor matching a specified selector outside of the Shadow Tree
.
Similarily, the ::slotted()
function can be applied for "external" elements inside slots
.
There is still a possibility to customize styles in the main document. That is the CSS custom properties
or variables feature applied to the Shadow DOM
. It is based on a few principles:
- The properties are defined and used inside the
Shadow DOM
. So the component's author is responsible for providing an API (or a list of properties that can be overwritten from outside). - Variables are applied to the
host
element in the main document. As the specification says, custom properties do inherit. That means that if one is not defined in the child element's level, it takes the parent's value.
When the custom element is using Shadow DOM
with such a style defined as:
h3 { color: var(--my-color); }
It will try to access the --my-color
variable. So, a developer that is using the element can attach a variable in his or her style definitions.
custom-element { --my-color: red; }
However, the --my-color
variable is not defined in the h3
selector in any style definition, it is inherited from the Shadow Root
's host
and it is applied to an internal element's DOM
. The h3
color's value is red.
It is reasonable to use the fallback or the default value for those publically-defined variables. If a customer is not specifying the variable, it still has a sensible value.
To summarize, the features that the Shadow DOM
offers are:
- The isolated
DOM
, separated from the document. - Composition of an element either with private or public access to a tree.
- The
slots
for the purpose of reusage. - The
CSS
is scoped, so a page's styles are not mixed with ones declared inside theShadow Tree
. This approach simplifies the styles’ definition and decreases the need for hierarchical selectors.
HTML Template
The HTML Template
specification is usually combined with Shadow DOM
and Custom Elements
. It explains a way to specify the layout without influencing the document itself. Any content inside the template
tag is not rendered on the page load and can be used later by the JavaScript
code to instantiate elements.
<template id="mytemplate">
<script>
alert()
</script>
</template>
In the given example, the browser will now show the popup; however, it is stated inside the script
tag.
const template = document.querySelector('#mytemplate')
const clone = document.importNode(template.content, true)document.body.appendChild(clone)
The popup appears only when the template is instantiated and added to the document. The content
is another Document Fragment
, which contains the specified HTML
structure. The importNode()
document's method generates a copy of the given node. The last argument it takes is the "deep" flag. By default it is false
and the only high level element will be copied, so true
should be used in order to copy the whole layout.
It makes sense to create template
elements containing the style
definition inside.
<template id="mytemplate">
<style>
:host { color: red }
</style>
</template>
The definition conforms to the natural HTML
syntax, is not rendered on the page load and can be used later when a developer needs to append the template's instance.
const template = document.querySelector('#mytemplate')
const div = document.createElement('div')
const root = div.attachShadow({ mode: 'open' })
const clone = document.importNode(template.content, true)
root.appendChild(clone)document.body.appendChild(div)
Summary
The Custom Elements
, Shadow DOM
, and HTML Template
specifications allow a developer to create reusable components with an encapsulated DOM
, styles, and behavior. The majority of the declared APIs are already available in major browsers and have polyfills to mimic the rest.
Have a nice coding and hope to see you in comments!