Web components are a new set of browser APIs that allow you to create custom HTML elements with their own encapsulated styles and functionality.
These APIs enable developers to build webapps in a component based manner, versus the “default” document based approach of the web.
The whole “Web Components” package is made out of four sub-APIs which can be used separately from each other as well. The real magic happens when these four come together though.
HTML templates allow you to declare fragments of markup which are parsed as HTML, go unused at page load, but can be instantiated later on at runtime.
They’re a place to put a big wad of HTML that you don’t want the browser to mess with at all… for any reason. Rafael Weinstein (HTML Templates specification author)
Creating a template is as simple as creating any other regular DOM element:
<template id="template-author"><h2>Rijk</h2><img src="..." alt="The author of this article (Rijk)" /></template>
Key takeaways:
We have had ways to import assets like CSS and JavaScript files into our HTML markup for a while now. With HTML imports, we now have a way to include HTML documents in other HTML documents. This isn’t limited to markup either. An import can also include it’s own CSS, JavaScript or anything else.
Importing HTML documents is very similar to including CSS files:
<link rel="import" href="/author.html" />
As soon as the imported file has been loaded, it’s embedded scripts will run and the contents can be used by referencing the import
property of the element:
const content = document.querySelector('link[rel="import"]').import;
This API can be used very effectively when importing other custom elements, as will be demoed in a bit.
One major note on the browser support of HTML Imports: Chrome has implemented and shipped it a while ago now, but WebKit and Firefox aren’t going to implement it at all. WebKit provides that they want to investigate a combination with ES6 modules.
Key takeaways:
link rel=import
de-duplicates all request automatically.async
attributedocument
from within an to-be-imported document, you are actually accessing the parent document. document.currentScript.ownerDocument
is the reference to the "child"-document.document.currentScript.ownerDocument
. Wrap nested scripts in an iife with document.currentScript.ownerDocument
as param to prevent this.From Google’s Shadow DOM Fundamentals:
Shadow DOM is just normal DOM with two differences:
- how it’s created/used and
- how it behaves in relation to the rest of the page.
Normally, you create DOM nodes and append them as children of another element. With shadow DOM, you create a scoped DOM tree that’s attached to the element, but separate from its actual children. This scoped subtree is called a shadow tree. The element it’s attached to is its shadow host. Anything you add in the shadows becomes local to the hosting element, including
_<style>_
. This is how shadow DOM achieves CSS style scoping.
You have probably used shadow DOM before, even when you didn’t realize it. The browser uses shadow DOM for a lot of different native HTML elements like a range input:
Shadow DOM of a standard <input type=”range”>
The shadow DOM API allows developers to create and attach their own shadow DOMs to elements, which goes great together with Custom Elements.
Key takeaways:
:host
selector to style parent containerposition: fixed
and other css positioning uses the component boundaries as "viewport"With custom elements, developers can create new HTML tags, extend existing HTML tags, or extend the components other developers have build. It provides a native way to create re-usable components.
Custom elements have to be defined in JavaScript. They have a couple of lifecycle hooks:
class ArticleAuthor extends HTMLElement {
// When element is created or upgradedconstructor() {...}
// When element is inserted into a documentconnectedCallback() {...}
// When element is removed from a documentdisconnectedCallback() {...}
// When an observed attribute changesattributeChangedCallback() {...}
// When the element is adopted into a new documentadoptedCallback() {...}}
// Register <article-author>customElements.define('article-author', ArticleAuthor);
Adding DOM to the element can be done by inserting markup into this.innerHTML
:
...connectedCallback() {this.innerHTML = '<b>Hi there!</b>';}...
A nicer way of adding contents (imo) is combining the above mentioned HTML Templates and Shadow DOM (see demo below).
Key takeaways:
<article-author>
)HTMLButtonElement
) instead of the default HTMLElement
. This will inherit all default functionality of this element (like tabindex on buttons). Sadly this hasn’t been implemented anywhere as of this moment.Let’s create a nasa-apod
tag, which renders NASA’s Astronomy Picture of the Day (APOD) using their API.
I’ll inline all CSS and JS in this demo for clarities sake. The full demo can be found at https://github.com/rijkvanzanten/nasa-apod.
The goal is to create an element which supports a caption
attribute like follows:
<nasa-apod caption="Supercool Web Components Demo"></nasa-apod>
Lets start by creating a nasa-apod.html
file which will create and register the custom element.
<!-- nasa-apod.html -->
<script>
class NasaApod extends HTMLElement {}
customElements.define('nasa-apod', NasaApod);
</script>
Our element needs to display an image. To achieve that, we are going to add a regular <img>
tag to the shadow DOM of our element.
First off, let’s start by attaching a shadow DOM to our element and adding a template for later use.
<!-- nasa-apod.html -->
<template><img src="" /><p></p></template>
<script>
class NasaApod extends HTMLElement {** constructor() {super();**
**this.attachShadow({mode: 'open'});
}**
}
customElements.define('nasa-apod', NasaApod);
</script>
To insert the template into the shadow DOM, we need to select it with document.querySelector
or another selection method. However, because this element is imported into another document, document
actually references the document that imports the current document. To get a reference to the ownerDocument
, we need to wrap our random-img.html
logic into a IIFE in which we provide document.currentScript.ownerDocument
as parameter:
<!-- nasa-apod.html -->
<template><img src="" /><p></p></template>
<script>
(function(ownerDocument) {
class NasaApod extends HTMLElement {constructor() {super();
this.attachShadow({mode: 'open'});
}
}
customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>
Now that we have a reference to the correct document, we can select the template and insert it into the shadow DOM.
<!-- nasa-apod.html -->
<template><img src="" /><p></p></template>
<script>
(function(ownerDocument) {
class NasaApod extends HTMLElement {constructor() {super();
this.attachShadow({mode: 'open'});
**const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);** }
}
customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>
Now that we have an element which can actually be rendered, let’s try it out by importing it into our main HTML document.
<!-- index.html -->
<!doctype html><html><head><meta charset="utf-8">
<!-- import and register the nasa-apod component -->**<link rel="import" href="nasa-apod.html" />**</head><body> <nasa-apod caption="Supercool Web Components Demo"></nasa-apod></body></html>
If your browser supports all webcomponents specs (only Chrome as of this moment), you should be able to see this in the inspector:
It doesn’t do anything particular useful yet, but it works!
Let’s continue by getting our element to fetch an image from the API and display it in the shadow DOM.
<!-- nasa-apod.html -->
<template><img src="" /><p></p></template>
<script>
(function(ownerDocument) {
class NasaApod extends HTMLElement {constructor() {super();
this.attachShadow({mode: 'open'});
const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
connectedCallback() {
const {shadowRoot} = this;
**fetch('**[**https://api.nasa.gov/planetary/apod?api\_key=DEMO\_KEY'**](https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY%27)**)
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);**
}
}
customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>
Well isn’t this just lovely?
The rendered picture in our <nasa-apod> element
Let’s add in support for the caption
attribute.
First, we need to let the element know that it needs to observe the caption
attribute and provide a getter and setter method for it. This allows the element to be updated in the main document by altering it’s attribute via either the inspector or JavaScript.
<!-- nasa-apod.html -->
<template><img src="" /><p></p></template>
<script>
(function(ownerDocument) {
class NasaApod extends HTMLElement {constructor() {super();
this.attachShadow({mode: 'open'});
const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
**static get observedAttributes() {
return \['caption'\];
}**
**get caption() {
return this.getAttribute('caption');
}**
**set caption(val) {
this.setAttribute('caption', val);
}**
connectedCallback() {
const {shadowRoot} = this;
fetch('[https://api.nasa.gov/planetary/apod?api\_key=DEMO\_KEY'](https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY%27))
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);
}
}
customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>
Then we need to update the contents of the paragraph tag <p>
in the shadow root dynamically.
<!-- nasa-apod.html -->
<template><img src="" /><p></p></template>
<script>
(function(ownerDocument) {
class NasaApod extends HTMLElement {constructor() {super();
this.attachShadow({mode: 'open'});
const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
static get observedAttributes() {
return \['caption'\];
}
get caption() {
return this.getAttribute('caption');
}
set caption(val) {
this.setAttribute('caption', val);
}
connectedCallback() {
const {shadowRoot} = this;
**const caption = this.getAttribute('caption');**
**if (caption) {
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = caption;
}**
fetch('[https://api.nasa.gov/planetary/apod?api\_key=DEMO\_KEY'](https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY%27))
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);
}
** attributeChangedCallback(name, oldVal, newVal) {if (name === 'caption') {const {shadowRoot} = this;const paragraph = shadowRoot.querySelector('p');paragraph.innerText = newVal;}}**}
customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>
If you update the attribute in the element inspector in your browser, or update them dynamically with JavaScript by setting the attribute, the element updates in real-time!
Last but not least, add some scoped styling to our element and call it a day.
<!-- nasa-apod.html -->
<template><style>:host {display: flex;flex-direction: column;align-items: center;padding: 15px;border: 2px dashed grey;width: max-content;}
**img {
max-width: 500px;
max-height: 500px;
object-fit: cover;
}**
**p {
text-align: center;
text-transform: uppercase;
color: grey;
}
</style>**<img src="" /><p></p></template>
<script>
(function(ownerDocument) {
class NasaApod extends HTMLElement {constructor() {super();
this.attachShadow({mode: 'open'});
const {shadowRoot} = this;
const template = ownerDocument.querySelector('template');
const instance = template.content.cloneNode(true);
shadowRoot.append(instance);
}
static get observedAttributes() {
return \['caption'\];
}
get caption() {
return this.getAttribute('caption');
}
set caption(val) {
this.setAttribute('caption', val);
}
connectedCallback() {
const {shadowRoot} = this;
const caption = this.getAttribute('caption');
if (caption) {
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = caption;
}
fetch('[https://api.nasa.gov/planetary/apod?api\_key=DEMO\_KEY'](https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY%27))
.then(res => res.json())
.then(res => {
const image = shadowRoot.querySelector('img');
image.src = res.hdurl;
})
.catch(err => console.error);
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'caption') {
const {shadowRoot} = this;
const paragraph = shadowRoot.querySelector('p');
paragraph.innerText = newVal;
}
}
}
customElements.define('nasa-apod', NasaApod);
}(document.currentScript.ownerDocument))
</script>
Note: I’ve used NASA’s demo api-key for this example which has quite a low rate limit. Sign-up for your own (free) api_key at https://api.nasa.gov.