Web components are built on three technologies:
Custom elements are the basis for web components. They combine a user interface with styles and code.
The shadow DOM provides separation between a web component and the host document. Attaching a ShadowRoot to a custom element separates styles and script code from the host document, while events from child elements can still reach the host document.
TEMPLATE and SLOT elements are used to dynamically build a custom element at run-time.
<html><head> <link rel="stylesheet" href="page-header.css" /> <link rel="stylesheet" href="main-menu.css" /> <link rel="stylesheet" href="page-breadcrumbs.css" /> <link rel="stylesheet" href="product-group.css" /> <link rel="stylesheet" href="page-footer.css" /> <script type="module" src="page-header"></script> <script type="module" src="main-menu"></script> <script type="module" src="page-breadcumbs"></script> <script type="module" src="product-group"></script> <script type="module" src="page-footer"></script> </head><body> <div class="page-header"></div> <div class="main-menu"></div> <div class="page-breadcrumbs"></div> <div class="product-group" data-group-id="A"></div> <div class="product-group" data-group-id="B"></div> <div class="product-group" data-group-id="C"></div> <div class="product-group" data-group-id="D"></div> <div class="page-footer"></div> </body></html>
There are two types of web components:
Autonomous custom elements derive from HTMLElement, the base class of all HTML elements. That means that you must explicitly implement some aspects of your component (which is not necessarily difficult nor tedious). Customized built-in elements derive from a specific HTML element, such as HTMLParagraph, and thereby inherit its behavior.
Autonomous custom elements can have internal custom states to store the component state, customized built-in elements cannot.
The first step to create a web component is to write a script that implements the component class and its logic:
class CustomElement extendsHTMLElement{ constructor ( ) { super( ); } }
For customized built-in elements, derive your class from the parent element type:
class CustomizedDiv extendsHTMLDivElement{ constructor ( ) { super( ); } }
The second step is to register your class with Window.customElements, the custom element registry:
customElements.define( "custom-element", CustomElement );
The first parameter is the tag name for our component, which must contain at least one dash character. The second parameter is our class name, or the name of constructor function in case you set up the prototype chain the old-fashioned way.
To register a customized built-in element, we must add an object to the argument list that carries the extends property:
The options parameter is an object which is only necessary for customized built-in elements:
customElements.define( "custom-element", CustomElement, { extends : "div" } );
The extends member is a String that specifies the tag-name of the HTML base element we derived our class from. In this case, it is the HTML DIV element.
NOTE that the tag-name should appear in lowercase letters, otherwise the element might not be recognized.
This first demonstration shows how to create an autonomous custom element. Let's have a look on the HTML code first:
<html><head> <link rel="stylesheet" href="custom-element.css" /> <script type="module" src="custom-element.js"></script> </head><body> <h1>Demo 1</h1><p class="subtitle"></p> <p>Here is an <custom-element>autonomous custom element</custom-element>. It has display:inline style by default.</p> </body></html>
We have a stylesheet with a rule that demands a red color for the text in the custom element:
custom-element {
color : indianred ;
}
The SCRIPT imports the code for the custom element class and the registration:
classCustomElementextendsHTMLElement{ constructor ( ) { console.log( "CustomElement constructor()" ); super( ); } } customElements.define( "custom-element",CustomElement) ;
The class CustomElement extends the HTMLElement and is introduced as custom-element to the customElements registry.
This example demonstrates the use of a customized built-in element, an HTMLDivElement.
classCustomElementextendsHTMLDivElement{ constructor ( ) { console.log( "CustomElement constructor()" ); super( ); } } customElements.define( "custom-element",CustomElement, { extends : "div" } ) ;
The custom element is used in an HTML document:
<html><head> <link rel="stylesheet" href="custom-element.css" /> <script type="module" src="custom-element.js"></script> </head><body> <h1>Demo 2</h1><p class="subtitle"></p> <p>This is a paragraph.</p> <div is = "custom-element">Here is a customized built-in element. It inherits the display:block style by default.</div> <p>Another paragraph.</p> </body></html>
The constructor of our class should not set up the custom element, attributes and child nodes might not yet be accessible when the constructor runs. Instead, lifecycle callback methods are used for component setup and cleanup in different situations:
Called each time the custom element is added to a document or introduced to the custom element registry. Purpose: Setup the custom element instance, create child elements, create a shadow DOM, add styles, et cetera.
Called each time the element is removed from the document. Place cleanup code here.
Called instead of connectedCallback() and disconnectedCallback() each time the element is moved to a different place in the same document, for example via Element.moveBefore() or Element.insertBefore(). When implemented, disconnectedCallback() and connectedCallback() are not called so that unnecessary element setup and cleanup code is avoided in order to maintain the current component state.
Called each time the element is moved to a different document, for example, from an IFRAMEd document to the host document.
Called when attributes are changed, added, removed, or replaced. See Responding to attribute changes for details.
One purpose of custom elements is to reduce typing effort for the web page authors. Therefore, the content, or at least some of the content, is generated by a script.Demo 3 demonstrates that by using a lifecycle callback method:
classRegistrationFormextendsHTMLFormElement{constructor( ){ console.log( "CustomElement constructor()" ); super( ); }connectedCallback( ){ console.log( "CutomElement.connectedCallback()" ); this.innerHTML = `<table><tbody><tr> <td><label for="firstName">First name:</label></td> <td><input id="firstName" type="text" /></td> </tr><tr> <td><label for="lastName">Last name:</label></td> <td><input id="lastName" type="text" /></td> </tr><tr> <td><label for="email">EMail:</label></td> <td><input id="email" type="text" /></td> </tr><tr> <td></td> <td><button>Submit</button></td> </tr></tbody></table> </form> ` ; } } customElements.define( "registration-form", RegistrationForm, { extends : "form" } ) ;
The HTML document is not cluttered with the code for the registration form:
<html><head> <link rel="stylesheet" href="registration-form.css" /> <script type="module" src="registration-form.js"></script> </head><body> <h1>Demo 3</h1><p class="subtitle">Lifecycle Callbacks</p> <formis="registration-form"></form> </body></html>
This example shows how custom elements is moved around in the document. The demo implements a number of custom list items, that can be moved up and down in the list, removed from the document then inserted again. This pattern can be used to reorder elements in a list with state-preserving moves. It demonstrates the use of connectedMoveCallback() lifecylcle callbacks. Consider the following HTML document:
<html><head> <link rel="stylesheet" href="custom-element.css" /> <script type="module" src="custom-element.js"></script> <script type="module"> ... </script> </head><body> <h1>Demo 4</h1><p class="subtitle">Lifecycle callbacks</p> <div id="toolbar"> <button id="insertElement" disabled>Insert</button> <button id="moveElement">Move</button> <button id="removeElement">Remove</button> </div> <divis="custom-element"id="customElement">Here is a customized built-in element. It inherits the display:block style by default.</div> <p id="first">This is the first paragraph.</p> <p id="second">This is the second paragraph.</p> </body></html>
In the HEAD we import the stylesheet and script for our custom element. Then there is another script to which we will come back in a few seconds.
The BODY hosts a tool bar with a number of buttons, below is our custom element and a few paragraphs with some text. The buttons shall be used to move our custom element around in the document, or remove or re-insert it. The second script contains the logic for that:
// Save a reference to our custom element const customElement = document.getElementById( "customElement" ) ;insertElement.addEventListener( "click" , ( ) => { first.before( customElement ); removeElement.toggleAttribute( "disabled" ); insertElement.toggleAttribute( "disabled" ); moveElement.toggleAttribute( "disabled" ); } ) ;removeElement.addEventListener( "click" , ( ) => { customElement.remove( ); removeElement.toggleAttribute( "disabled" ); insertElement.toggleAttribute( "disabled" ); moveElement.toggleAttribute( "disabled" ); } ) ;moveElement.addEventListener( "click" , ( ) => { document.body.moveBefore( customElement, ! customElement.nextElementSibling ?document.getElementById( "first" ) : customElement.nextElementSibling.nextElementSibling ); } ) ;
The first instruction saves a reference to our custom element, otherwise it would be gone as soon as it was removed from the document.
The first event handler inserts the custom element into the document before the first paragraph. The second one uses the method moveBefore() to move the custom element further down the document. The third one removes the element from the document.
Here is the custom element code:
class CustomElement extends HTMLDivElement {
constructor ( ) {
console.log( "CustomElement constructor()" );
super( );
}
connectedCallback ( ) {
console.log( "connectedCallback()" );
}
disconnectedCallback ( ) {
console.log( "disconnectedCallback()" );
}
connectedMoveCallback ( ) {
console.log( "connectedMoveCallback()" );
}
adoptedCallback ( ) {
console.log( "adoptedCallback()" );
}
}
customElements.define( "custom-element", CustomElement, { extends : "div" } ) ;
The lifecycle callbacks essentially do nothing, they only log a message to the brower console.
An inspection on these messages produces the following results:
This demonstrates the moving a custom element in a template to the host document. The HTML:
<html><head>
<link rel="stylesheet" href="registration-form.css" />
<script type="module" src="registration-form.js"></script>
<script type="module">
register.addEventListener( "click" , ( ) => {
debugger;
// Clone the registration form
const node = document.importNode( registrationFormTemplate.content, true );
// and insert the clone into the document.
document.body.append( node );
} ) ;
</script>
</head><body>
<h1>Demo 5</h1><p class="subtitle">Instantiation from a template</p>
<template id="registrationFormTemplate">
<!-- This template hosts the registration form element -->
<form is="registration-form"></form>
</template>
<button id="register">Register</button>
<p>Click the button to open the registration form.</p>
</body></html>
Custom elements can declare a list of attributes to be monitored in their static observedAttributes property:
class myComponent extends HTMLElement {
// Define custom attributes
static observedAttributes = [
"attributeA", "attibuteB" ];
To monitor attribute changes, add the attributeChangedCallback() method to the class:
attributeChangedCallback( attributeName, oldValue, newValue ) { console.log( `Attribute ${ attributeName } has changed from ${ oldValue } to ${ newValue }.` ); }
Built-in elements have internal states, such as :hover or :checked. Autonomous custom elements can implement their own internal custom state properties, customized built-in element cannot. Internal means that the state cannot be modified from the outside, only from the inside of the custom element.
To make internal states available to external CSS selectors as read-only properties, call the attachInternals() method in the constructor:
class webComponent extendsHTMLElement{ constructor ( ) { super( ); this.internals = this.attachInternals( ); }
Then add getters and setters for custom states:
getsomeState( ) { return this.internals.states.has( "someState" ); } setsomeState( value ) { const method = value ? "add" : "delete" ; this.internals.states[ method ]( "someState" ); }
The :state() CSS pseudo-class function can be used to select elements with a specific state:
web-component:state(someState) { color : red }
It appears that custom state attributes are limited to boolean values.
This project demonstrates a simple autonomous custom element. It is derived from HTMLElement. The GUI elements and styles are created in the connectCallback() method.
A shadow-root node creates an access barrier for JavaScript and CSS between the "regular" DOM tree of the host document and the DOM tree formed by the child nodes of the shadow root node, and thereby provides a means of encapsulation for web components. The elements in a shadow tree are still rendered on the screen, but this barrier ensures that the web component works in all kinds of host documents.
The following study sets up a document with a customized built-in element:
<html><head> <script type="module" src="myComponent.js?shadowmode=closed"></script> </head><body> <div id="host" is="my-component"></div> <span>A SPAN in the regular DOM tree of the document</span> </body></html>
<style>
span { color : red } /* has no effect on the SPAN in the component's shadow tree */
</style>
<script type="module">
console.log( document.getElementById( "span-in-shadowtree" )); // null
console.log( document.getElementById( "host" ).shadowRoot?.getElementById( "span-in-shadowtree" )); // <span id="span-in-shadowtree">SPAN in the shadow DOM</span> or undefined
</script>
CSS code that is located in the "regular" DOM tree cannot affect elements in a shadow DOM tree. Scripts that are located in the "regular" DOM tree of the the document cannot access elements in a shadow DOM tree that was created with shadowmode="closed".
The shadow root is an element that is attached to a regular DOM element:
const hostElement = document.querySelector( "div#shadowDomHost" );
const shadowRoot = hostElement.attachShadow( { mode : "open" } );
const span = document.createElement( "SPAN" );
span.textContent = "I'm in the shadow DOM";
shadowRoot.append( span );
A shadow tree can be created programmatically, or declaratively as in the following example:
<div id="host">
<template shadowrootmode="open">
<span>I'm in the shadow DOM</span>
</template>
</div>
The addition of the attribute shadowrootmode="open" makes the elements in the shadow tree visible. In the absence of this attribute, the shadow tree is not rendered.
Elements in the shadow tree are not accessible from the document:
const e = document.querySelector( "#elementInShadowTree" );
Similarly, CSS code in the document cannot address elements in a shadow tree.
To access the elements in a shadow tree, the shadow root element must be used:
const e = shadowTreeHost.shadowRoot.querySelector( "#elementInShadowTree" );
If you specifiy mode : "closed", this oportunity is eliminated, the shadow root is completely opacque. However, this is not a security feature, because there are ways to overcome this barrier.
CSS can be injected into the shadow tree, either programmatically (by creating a STYLE element, or programmatically, by adding a STYLE element to the TEMPLATE code. Note that CSS in the shadow tree has no effect outside the tree.
Elements in a shadow DOM tree can still fire events that bubble through the shadow root into the "regular" DOM tree.
This might cause a visible flicker known as "flash of unstyled content (FOUC)". To prevent this, it is advisable to hide the element and make it visible at the end of connectedCallback().