Web components are a set of web platform APIs that allow you to create new custom, reusable, encapsulated HTML tags to use in web pages and web apps. Custom components and widgets build on the Web Component standards, will work across modern browsers, and can be used with any JavaScript library or framework that works with HTML.
Web components are based on existing web standards. Features to support web components are currently being added to the HTML and DOM specs, letting web developers easily extend HTML with new elements with encapsulated styling and custom behavior.
We will build a random-greeter component that will output a random greet at a specific interval.
Fire up a code editor, and create two files index.html
and random-greeter.html
.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Web Component Basics</title>
</head>
<body>
</body>
</html>
<script>
// Extend HTMLElement base class
class RandomGreeter extends HTMLElement {
static get is() { return 'random-greeter' }
connectedCallback() {
console.log('random-greeter element added to the DOM!');
}
}
// Register custom element definition using standard platform API
customElements.define(RandomGreeter.is, RandomGreeter);
</script>
random-greeter
componentTo employ <random-greeter>
, you need to:
index.html
.body
.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Web Component Basics</title>
<link rel="import" href="random-greeter.html">
</head>
<body>
<random-greeter></random-greeter>
</body>
</html>
Just open the index.html
page in the browser, open the developer tools a look at the Console
tab where you should see:
Custom Elements allow you to define behavior for custom HTML tags. You can define custom elements by extending a class
from HTMLElement
. For example, let's look at the JavaScript of random-greeter.html
:
// Extend HTMLElement base class
class RandomGreeter extends HTMLElement {
static get is() { return 'random-greeter' }
connectedCallback() {
console.log('random-greeter element added to the DOM!');
}
}
// Register custom element definition using standard platform API
customElements.define(RandomGreeter.is, RandomGreeter);
The static is
getter is where you should define the name of your element. By convention, we use the dash-separated version of the class name.
The Custom Elements V1 specification includes a set of callbacks that run at various points in the element's lifecycle.
Callback | Description |
| Called when an instance of the element is created or upgraded. Useful for initializing state, settings up event listeners, or creating shadow dom. Always call Note: See the V1 Custom Elements spec for restrictions on what you can do in the |
| Called every time the element is inserted into the DOM. Useful for running setup code, such as fetching resources or rendering. Note: Generally, you should try to delay work until this time. |
| Called every time the element is removed from the DOM. Useful for running clean up code (removing event listeners, etc.). |
| An attribute was added, removed, updated, or replaced. Also called for initial values when an element is created by the parser, or upgraded. Note: Only attributes listed in the |
The browser calls the attributeChangedCallback()
for any attributes whitelisted in the observedAttributes array. Essentially, this is a performance optimization. When users change a common attribute like style or class, you don't want to be spammed with tons of callbacks.
// Extend HTMLElement base class
class RandomGreeter extends HTMLElement {
...
static get observedAttributes() {
return ['attr1', 'attr2'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
console.log('Attribute Changed!', attrName, oldValue, newValue);
}
...
}
// Register custom element definition using standard platform API
customElements.define(RandomGreeter.is, RandomGreeter);
Implement all the lifecycle callbacks for the random-greeter
element and observe them in the Chrome DevTools' console tab
The HTML <template>
element represents a template in your markup. It contains "template contents"; essentially inert chunks of cloneable DOM. Think of templates as pieces of scaffolding that you can use (and reuse) throughout the lifetime of your app.
To create a templated content, declare some markup and wrap it in the <template>
element:
<template id="mytemplate">
<img src="" alt="great image">
<div class="comment"></div>
</template>
Wrapping content in a <template>
gives us few important properties:
Its content is effectively inert until activated. Essentially, your markup is hidden DOM and does not render.
Any content within a template won't have side effects. Script doesn't run, images don't load, audio doesn't play,...until the template is used.
Content is considered not to be in the document. Using document.getElementById()
or querySelector()
in the main page won't return child nodes of a template.
Templates can be placed anywhere inside of <head>
, <body>
, or <frameset>
and can contain any type of content which is allowed in those elements. Note that "anywhere" means that <template>
can safely be used in places that the HTML parser disallows...all but content model children. It can also be placed as a child of <table>
or <select>
To use a template, you need to activate it. Otherwise its content will never render. The simplest way to do this is by creating a deep copy of its .content
using document.importNode()
. The .content
property is a read-only DocumentFragment
containing the guts of the template.
var t = document.querySelector('#mytemplate');
// Populate the src at runtime.
t.content.querySelector('img').src = 'logo.png';
var clone = document.importNode(t.content, true);
document.body.appendChild(clone);
random-greeeter
Add the following template at the top of the random-greeter.html
file
<template>
<style>
.greeter-frame {
background-color: #FF6600;
text-align: center;
padding: 20px;
}
h1 {
padding: 0;
margin: 0;
color: #ffffff;
}
</style>
<div class="greeter-frame">
<h1 id="greet">Hello, World!</h1>
</div>
</template>
And activate it
// Returns the top-level document object of the <script> element whose script is currently being processed.
const $owner = (document._currentScript || document.currentScript).ownerDocument;
const $template = $owner.querySelector('template');
// Extend HTMLElement base class
class RandomGreeter extends HTMLElement {
...
connectedCallback() {
console.log('random-greeter element added to the DOM!');
// Performs a deep import
const $content = document.importNode($template.content, true);
this.appendChild($content);
}
...
}
// Register custom element definition using standard platform API
customElements.define(RandomGreeter.is, RandomGreeter);
Refresh the browser and you will be greeted.
random
in random-greeter
The random-greeter
should cycle through some greets at a specific interval. Basically what we want is to replace the content of the greet
H1 tag with a random text each x seconds.
Inside the constructor
set up some hardcoded greets
constructor() {
...
this._greets = [
"Hello, World!",
"Hi, Solar System!",
"Yo, Galaxy!",
"Hey, Universe!"
];
this._$greet = null;
}
Inside the connectedCallback
function set the _$greet
property and start a timer that calls the _render
function each 2s.
connectedCallback() {
...
this._$greet = this.querySelector("#greet");
this._timer = setInterval(() => this._render(), 2000);
}
_render() {
if (this._$greet !== null) {
const index = Math.floor(Math.random() * this._greets.length);
this._$greet.innerHTML = this._greets[index];
}
}
greet
h1 tag. Now when the browser is refreshed there is no greeting shown. Make sure we see a value without hardcoding it.setInterval
function when the component is removed from the DOMThe difference between properties and attributes can be confusing. Properties are available on a DOM node when being manipulated by JavaScript:
const myElem = document.querySelector('.my-elem');
myElem.className; // className is a property
And attributes are provided in the HTML itself. Here alt
, width
and height
are all attributes:
<img src="/path/to/img.svg" alt="My Image" width="150" height="250">
Most properties reflect their values as attributes, meaning that if the property is changed using JavaScript, the corresponding attribute is also changed at the same time to reflect the new value. This is useful for accessibility and to allow CSS selectors to work as intended.
You can try it out yourself for a concrete example. Just select, say, an image element in your browser’s developer tools, and then change one of its properties:
const fancyImage = document.querySelector('.fancy-image');
fancyImage.width = 666;
Notice how the with
attribute in the DOM representation is automatically changed to the new value. The same is true if you change the value for the attribute manually in the DOM inspector, you’ll see that the property will now hold the new value.
Your own Custom Elements should also follow this practice of reflecting properties to attributes. Luckily, it’s quite easy to do using getters and setters.
For example, if you have a custom element that has a value
property that should be reflected as an attribute, here’s how you would use a getter and a setter to get the value of the attribute when doing property access and setting the new value for the attribute when the property is changed:
get value() {
return this.getAttribute('value');
}
set value(newValue) {
this.setAttribute('value', newValue);
}
Or, if you have a boolean property, like, say hidden
:
get hidden() {
return this.hasAttribute('hidden');
}
set hidden(isHidden) {
if (isHidden) {
this.setAttribute('hidden', '');
} else {
this.removeAttribute('hidden');
}
}
With Custom Elements, you can listen for attribute changes using the attributeChangedCallback
method. This makes it easy to trigger actions when attributes are changed. To help with performance, only attributes defined with an observedAttributes
getter that returns an array of observed attribute names will be observed.
The attributeChangedCallback
is defined with three parameters, the name of the attribute, the old value and the new value. In this example, we observe the value
and max
attributes:
static get observedAttributes() {
return ['value', 'max'];
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'value':
console.log(`Value changed from ${oldValue} to ${newValue}`);
break;
case 'max':
console.log(`You won't max-out any time soon, with ${newValue}!`);
break;
}
}
Let’s put all these concepts together and update the random-greeter
component.
// Returns the top-level document object of the <script> element whose script is currently being processed.
const $owner = (document._currentScript || document.currentScript).ownerDocument;
const $template = $owner.querySelector('template');
// Extend HTMLElement base class
class RandomGreeter extends HTMLElement {
static get is() {
return 'random-greeter'
}
constructor() {
super();
console.log('random-greeter element constructed!');
this._greets = [
"Hello, World!",
"Hi, Solar System!",
"Yo, Galaxy!",
"Hey, Universe!"
];
this._$greet = null;
this._timer = null;
}
connectedCallback() {
console.log('random-greeter element added to the DOM!');
// Performs a deep import
const $content = document.importNode($template.content, true);
this.appendChild($content);
this._$greet = this.querySelector("#greet");
if (!this.hasAttribute('interval')) {
this.interval = 2000;
}
this._render();
}
disconnectedCallback() {
console.log('random-greeter element removed from the DOM!');
clearInterval(this._timer);
}
static get observedAttributes() {
return ['interval'];
}
attributeChangedCallback(name, oldValue, newValue) {
console.log('Attribute Changed!', name, oldValue, newValue);
if (this._timer !== null) {
clearInterval(this._timer);
}
if (newValue > 0) {
this._timer = setInterval(() => this._render(), newValue);
}
}
get interval() {
return this.getAttribute('interval');
}
set interval(newValue) {
this.setAttribute('interval', newValue);
}
set greets(greets) {
if (this._greets === greets) return;
this._greets = greets;
this._render();
}
get greets() {
return this._greets;
}
_render() {
if (this._$greet !== null) {
const index = Math.floor(Math.random() * this._greets.length);
this.setAttribute("current-index", index);
this._$greet.innerHTML = this._greets[index];
}
}
}
// Register custom element definition using standard platform API
customElements.define(RandomGreeter.is, RandomGreeter);
Play with the element in Chrome Developer Tools and try to understand the differences.
Shadow DOM removes the brittleness of building web apps. The brittleness comes from the global nature of HTML, CSS, and JS. For example, when you use a new HTML id/class, there's no telling if it will conflict with an existing name used by the page. Subtle bugs creep up, CSS specificity becomes a huge issue (!important all the things!), style selectors grow out of control, and performance can suffer.
Shadow DOM fixes CSS and DOM. It introduces scoped styles to the web platform. Without tools or naming conventions, you can bundle CSS with markup, hide implementation details, and author self-contained components in vanilla JavaScript.
Shadow DOM is designed as a tool for building component-based apps. Therefore, it brings solutions for common problems in web development:
Shadow DOM is just normal DOM with two differences:
Normally, DOM nodes are created and appended as children of another element. With shadow DOM, a create a scoped DOM tree that's attached to the element, but separate from its actual children. This scoped subtree (named shadow tree) is created. The element it's attached to its shadow host. Anything added in the shadow tree becomes local to the hosting element, including <style>
. This is how shadow DOM achieves CSS style scoping.
A shadow root is a document fragment that gets attached to a “host” element. The act of attaching a shadow root is how the element gains its shadow DOM. To create shadow DOM for an element, call element.attachShadow()
:
const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().
// header.shadowRoot === shadowRoot
// shadowRoot.host === header
Shadow DOM is particularly useful when creating custom elements. Use shadow DOM to compartmentalize an element's HTML, CSS, and JS, thus producing a "web component".
A custom element attaches shadow DOM to itself, encapsulating its DOM/CSS this.attachShadow({mode: 'open'});
random-greeter
Before creating a shadow root for the random-greeter
component let's simulate possible conflicts.
Inside the index.html
file add the following style
block:
<style>
h1 {
color: #ff6600 !important;
}
</style>
and refresh the browser. Is this something that could happen? Of course, it is. Now let's fix it by creating a shadow root
...
constructor() {
...
this._root = this.attachShadow({"mode": "open"});
}
connectedCallback() {
...
//this.appendChild($content);
this._root.appendChild($content);
//this._$greet = this.querySelector("#greet");
this._$greet = this._root.querySelector("#greet");
...
}
Shadow DOM composes different DOM trees together using the <slot>
element. Slots are placeholders inside your component that users can fill with their own markup. By defining one or more slots, you invite outside markup to render in your component's shadow DOM. Essentially, you're saying "Render the user's markup over here".
Elements are allowed to "cross" the shadow DOM boundary when a <slot> invites them in. These elements are called distributed nodes. Conceptually, distributed nodes can seem a bit bizarre. Slots don't physically move DOM; they render it at another location inside the shadow DOM.
A component can define zero or more slots in its shadow DOM. Slots can be empty or provide fallback content. If the user doesn't provide light DOM content, the slot renders its fallback content.
You can also create named slots. Named slots are specific holes in your shadow DOM that users reference by name.
<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>
<slot>fallback content</slot> <!-- default slot with fallback content -->
<slot> <!-- default slot entire DOM tree as fallback -->
<h2>Title</h2>
<summary>Description text</summary>
</slot>
<!-- Named slot use slot="title" when referencing-->
<slot name="title"></slot>
random-greeter
Let's add two slots to the random-greeter
component, a named and a default one
<div class="greeter-frame">
<slot name="top"></slot>
<h1 id="greet"></h1>
<slot class="bottom"></slot>
</div>
Now in index.html
<random-greeter>
<marquee behavior="alternate" direction="right" slot="top">#BBConf rulz!!!</marquee>
<ul>
<li>Something</li>
<li>More something</li>
<li>About</li>
</ul>
</random-greeter>
Experiment with slots
:host
The :host
selector makes it easy to select and style the custom element itself, meaning the shell of the element. Let’s add some styles to our random-greeter
element:
:host {
all: initial;
display: block;
contain: content;
text-align: center;
padding: 20px;
background-color: #FF6600;
max-width: 500px;
margin: 0 auto;
border-radius: 8px;
transition: transform .2s ease-out;
}
:host([hidden]) {
display: none;
}
:host(:hover) {
transform: scale(1.1);
}
all: initial
to reset all global style values so that they don’t affect our element’s styles. Note that this should be the first property that you set in your CSS rule, otherwise what follows could be reset too.display: inline
, so here we specify that it should be display: block
instead.:host
selector can also be used as a function to select only the element if it matches the provided selector. We use that here to set display: none
on the element if has the hidden attribute.contain
, to hint to the CSS engine that the styles are contained and independent. This can help with layout performance in the browsers that support it.Note that if a custom element is styled externally by the user of the element with something like this…
random-greeter {
background: none;
}
…the external styles will always win if they collide with the ones provided with :host
. So in that case, our element wouldn’t have its background color anymore.
:host-context
We can also use a :host-context()
selector that will select our custom element only if it or any of its ancestors match the provided selector. This makes it really easy to style your element differently if, say, the element has the dark
or light
class:
:host-context(.dark) {
background-color: #e65c00;
}
:host-context(.light) {
background-color: #e65c00;
}
So far we’ve seen how we can easily style a custom element from within, but what if we want to let the user of the element customize the styles without having to modify the element? This is where CSS custom properties come
in.
In random-greeter
' element’s style definition, you’d use the custom properties like this:
:host {
...
background-color: var(--var-bg-color, #FF6600);
...
}
...
h1 {
...
color: var(--var-color, #ffffff);
@apply --greeter-styles;
}
With this in place, our default colors will be used if variables are not defined, and now the colors can be customized in page’s style using something like this:
random-greeter {
--var-bg-color: navy;
--var-color: green;
--greeter-styles: {
font-family: "Bauhaus 93";
font-size: 40px;
border: dotted 5px yellow;
}
}
::slotted(<compound-selector>)
matches nodes that are distributed into a <slot>.
::slotted(marquee) {
color: yellow;
font-size: 20px;
}
.bottom::slotted(*) {
text-align: left;
color: #ffffff;
}
In this step, we’ll quickly go over the steps to transpile our custom element code to be compatible with ES5 and to include the necessary Web Components polyfills.
random-greeter
element code contains ES6 classes and string literals and arrow functions. These features are great to use at author-time, but the code has to be transpiled using a tool like Babel so that it can run in browsers that only understand ES5 code. We’ll go ahead and use babel-cli and the babel-preset-es2015 preset do to just that.
If your project doesn’t have a package.json
file just yet, go ahead an create one using either npm
or Yarn
. This will allow you to install the necessary packages:
$ npm init
# or, using Yarn
$ yarn init
Once this is done, you can go ahead and install the necessary packages:
$ npm install babel-cli babel-preset-es2015 crisper vulcanize rimraf --save-dev
# or, using Yarn
$ yarn add babel-cli babel-preset-es2015 crisper vulcanize rimraf --dev
We’ll output our transpiled code into a dist
folder, so you can go ahead and create that folder at the root of your project. Now you can some scripts script to the package.json file that runs Babel with the es2015 preset and outputs the result in the dist folder:
{
...
"scripts": {
"clean": "rimraf dist",
"prebuild": "npm run clean -s && mkdir dist",
"build:vulcanize": "vulcanize random-greeter.html --inline-script --inline-css | crisper -h dist/random-greeter.html -j dist/random-greeter.js",
"build": "npm run build:vulcanize && babel --presets es2015 dist/random-greeter.js --out-file dist/random-greeter.js"
},
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-preset-es2015": "^6.24.1",
"crisper": "^2.1.1",
"rimraf": "^2.6.2",
"vulcanize": "^1.16.0"
}
}
Now you can go ahead and run the transpiling:
$ npm run build
# or, using Yarn
$ yarn build
Polyfills are small plugins that implement a feature to replace a missing native implementation for browsers that don’t have support yet. For Web Components, we’ll need polyfills for Custom Elements, Shadow DOM and the template element.
There’s also a loader available, webcomponents-loader.js
, that will perform some feature detection and include the necessary polyfills on the fly. Using the loader is probably the simplest way to get everything working, and this is what we’ll use here for our example.
Custom elements need to be defined using ES6 classes that extend HTMLElement
, but now we’re using transpiled code that has the class syntax stripped away. We’ll need to use a file called custom-elements-es5-adapter.js
to fix that for us.
Simply install the @webcomponents/webcomponentsjs
package into your project using npm
or yarn
. This will install the necessary files in the node_modules folder:
$ npm install @webcomponents/webcomponentsjs --save
# or, using Yarn
$ yarn add npm install @webcomponents/webcomponentsjs
Now, in your page’s head, include custom-elements-es5-adapter.js
and webcomponents-loader.js
:
<script src="node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
The final step is only include the script for your transpiled custom element when the WebComponentsReady
event fires. That’s a custom event that gets fired by the loader when the necessary polyfills have been loaded. We’ll simply create a link
element, set its rel
and src property to the path of our transpiled custom element code and append it to the document’s head:
window.addEventListener('WebComponentsReady', function () {
var randomGreeterImport = document.createElement('link');
randomGreeterImport.rel = 'import';
randomGreeterImport.href = 'dist/random-greeter.html';
document.head.appendChild(randomGreeterImport);
});
Here’s how our final index.html
file looks like:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Web Component Basics</title>
<script src="node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<style>
h1 {
color: #ff6600 !important;
}
random-greeter {
--var-bg-color: navy;
--var-color: green;
--greeter-styles: {
font-family: "Bauhaus 93";
font-size: 40px;
border: dotted 5px yellow;
}
}
</style>
</head>
<body>
<random-greeter class="light"></random-greeter>
<random-greeter class="dark"></random-greeter>
<random-greeter>
<marquee behavior="alternate" direction="right" slot="top">#BBConf rulz!!!</marquee>
<ul>
<li>Something</li>
<li>More something</li>
<li>About</li>
</ul>
</random-greeter>
<script>
window.addEventListener('WebComponentsReady', function () {
var randomGreeterImport = document.createElement('link');
randomGreeterImport.rel = 'import';
randomGreeterImport.href = 'dist/random-greeter.html';
document.head.appendChild(randomGreeterImport);
});
</script>
</body>
</html>
Custom elements allow you to extend HTML and define your own tags. They're an incredibly powerful feature, but they're also low-level, which means it's not always clear how best to implement your own element
Here is a checklist which breaks down the things we applied to create a well behaved custom element.