HTML Web Component using Vanilla JavaScript
Web Components have been around for a while now. Google has really been trying to push for their more widespread adoption, but most major browsers still have very little support for it, except for Opera and Chrome.
However, by using polyfills, available from https://www.webcomponents.org/polyfills, you can build your own Web Components now.
In this article, I’m going to teach you how to create your own HTML tags with styles, functionality, and markup neatly packaged in their own files.
Edit: I’ve written another post on using Web components. You can check it out at HTML Web Component using Vanilla JS - Part 2!
Introduction
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.
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.
It allows you to create reusable components using nothing more than vanilla JS/HTML/CSS. If HTML doesn’t provide the solution to a problem, we can create a Web Component that does.
For example, you have user data associated with an ID and want a component which fetches and populates that data given a user ID as an input. The HTML would be as follows:
<user-card user-id="1"></user-card>
This is a pretty basic use case for Web Components. This tutorial will focus on building the user card component.
Four Pillars of Web Components
The HTML and DOM standards define four new standards/APIs that are helpful for defining Web Components. These standards are:
- Custom Elements: With Custom Elements, web developers can create new HTML tags, beef-up existing HTML tags, or extend the components other developers have authored. This API is the foundation of Web Components.
- HTML Templates: It defines a new
<template>
element, which describes a standard DOM-based approach for client-side templating. 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. - Shadow DOM: Shadow DOM is designed as a tool for building component-based apps. It brings solutions for common problems in web development. It allows you to isolate DOM for the component and scope, and simplify CSS, etc.
- HTML Imports: While HTML templates allow you to create new templates, HTML imports allows you to import these templates from different HTML files. Imports help keep code more organized by neatly arranging your components as separate HTML files.Note: HTML imports might go away and are also not widely supported right now. Please read this discussion for more info.
Defining a Custom Element
For creating a Custom element, we first have to declare a class for the custom element that defines how the element will behave. This class needs to extend the HTMLElement
class. Let’s take a detour and first discuss some of the lifecycle methods of custom elements. You can use the following lifecycle callbacks with custom elements:
connectedCallback
— Called every time the element is inserted into the DOM.disconnectedCallback
— Called every time the element is removed from the DOM.attributeChangedCallback
— The behavior occurs when an attribute of the element is added, removed, updated, or replaced.
Create a new file called UserCard.js
in a folder called UserCard
.
class UserCard extends HTMLElement {
constructor() {
// If you define a constructor, always call super() first as it is required by the CE spec.
super();
// Setup a click listener on <user-card>
this.addEventListener('click', e => {
this.toggleCard();
});
}
toggleCard() {
console.log("Element was clicked!");
}
}
customElements.define('user-card', UserCard);
In this example, we have set up a Class that defines some of the behavior of our Custom Element, user-card
. The customElements.define('user-card', UserCard);
call tells the DOM that we have created a new custom element called user-card
, whose behaviour is defined by UserCard
. Now we can use the user-card element in our HTML.
We’ll be using the following API from https://jsonplaceholder.typicode.com/
to create our User cards. Here’s an example of how the data will look:
{
id: 1,
name: "Leanne Graham",
username: "Bret",
email: "Sincere@april.biz",
address: {
street: "Kulas Light",
suite: "Apt. 556",
city: "Gwenborough",
zipcode: "92998-3874",
geo: {
lat: "-37.3159",
lng: "81.1496"
}
},
phone: "1-770-736-8031 x56442",
website: "hildegard.org"
}
Creating a template
Now, let’s create a template that’ll be rendering this data on screen. Create a new file called UserCard.html with the following code:
<template id="user-card-template">
<div class="card__user-card-container">
<h2 class="card__name">
<span class="card__full-name"></span> (
<span class="card__user-name"></span>)
</h2>
<p>Website: <a class="card__website"></a></p>
<div class="card__hidden-content">
<p class="card__address"></p>
</div>
<button class="card__details-btn">More Details</button>
</div>
</template>
<script src="/UserCard/UserCard.js"></script>
Note: See that I’ve used class to have a prefix of card__
. This is because in older browsers, we cannot isolate the DOM using shadow DOM. When styling the DOM, we won’t be accidentally styling, say, a class called name.
Styling
We have now created a template for our card. Now, let’s style it using CSS. Create a new file called UserCard.css in UsedCard folder with the following content:
.card__user-card-container {
text-align: center;
display: inline-block;
border-radius: 5px;
border: 1px solid grey;
font-family: Helvetica;
margin: 3px;
width: 30%;
}
.card__user-card-container:hover {
box-shadow: 3px 3px 3px;
}
.card__hidden-content {
display: none;
}
.card__details-btn {
background-color: #dedede;
padding: 6px;
margin-bottom: 8px;
}
Now, include this CSS file in your template using the following tag at the beginning of the <template>
tag in the UserCard.html
file:
<link rel="stylesheet" href="/UserCard/UserCard.css">
With our styles and templates in place, we can now move on to making our component functional.
connectedCallback
Now we need to define what happens when we create an element and attach it to the DOM. Note that there is a difference between the constructor
and the connectedCallback
method.
constructor
is called when an instance of the element is created, while connectedCallback
is called every time the element is inserted into the DOM. It is useful for running setup code, such as fetching resources or rendering.
Note: At the top of your UserCard.js
file, define a constant called currentDocument
. It is needed in imported HTML’s scripts to allow them access to the DOM of the imported HTML. Define it as follows:
const currentDocument = document.currentScript.ownerDocument;
Let us define our connectedCallback
:
// Called when element is inserted in DOM
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
// Select the template and clone it. Finally attach the cloned node to the shadowDOM's root.
// Current document needs to be defined to get DOM access to imported HTML
const template = currentDocument.querySelector('#user-card-template');
const instance = template.content.cloneNode(true);
shadowRoot.appendChild(instance);
// Extract the attribute user-id from our element.
// Note that we are going to specify our cards like:
// <user-card user-id="1"></user-card>
const userId = this.getAttribute('user-id');
// Fetch the data for that user Id from the API and call the render method with this data
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then((response) => response.text())
.then((responseText) => {
this.render(JSON.parse(responseText));
})
.catch((error) => {
console.error(error);
});
}
Rendering the user data
We have our connectedCallback
in place now. We created a shadow root and attached our template’s clone to it. Now we need to populate that clone. For that, we called the render
method from our fetch
call. Let’s create the render
method and toggleCard
method.
render(userData) {
// Fill the respective areas of the card using DOM manipulation APIs
// All of our components elements reside under shadow dom. So we created a this.shadowRoot property
// We use this property to call selectors so that the DOM is searched only under this subtree
this.shadowRoot.querySelector('.card__full-name').innerHTML = userData.name;
this.shadowRoot.querySelector('.card__user-name').innerHTML = userData.username;
this.shadowRoot.querySelector('.card__website').innerHTML = userData.website;
this.shadowRoot.querySelector('.card__address').innerHTML = `<h4>Address</h4>
${userData.address.suite}, <br />
${userData.address.street},<br />
${userData.address.city},<br />
Zipcode: ${userData.address.zipcode}`
}
toggleCard() {
let elem = this.shadowRoot.querySelector('.card__hidden-content');
let btn = this.shadowRoot.querySelector('.card__details-btn');
btn.innerHTML = elem.style.display == 'none' ? 'Less Details' : 'More Details';
elem.style.display = elem.style.display == 'none' ? 'block' : 'none';
}
Now that we have our component in place, we can use it in our projects. Any of them. So for the sake of this tutorial, create a new HTML file called index.html
and write the following code in it:
<html>
<head>
<title>Web Component</title>
</head>
<body>
<user-card user-id="1"></user-card>
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.14/webcomponents-hi.js"></script>
<link rel="import" href="./UserCard/UserCard.html">
</body>
</html>
We need to add the webcomponents.js file, as not all browsers support Web Components. Note that we’re using the HTML import statement to import our component from the directory.
To run this code, you’ll need to create a static file server. If you don’t know how to do that, you can use a simple static server like static-server
or json-server
. For this tutorial, install static-server
using:
$ npm install -g static-server
Now, navigate to your folder containing the index.html file using cd
and run the server using:
$ static-server
Open your browser and go to localhost:3000, and you should see the component we just created.
Tips and Tricks
There are a lot of things we did not cover in this relatively short article about Web Components. I’d like to succinctly state some tips and tricks that were useful when developing Web Components.
Naming Components
- The name of a custom element must contain a dash. So
<my-tabs>
and<my-amazing-website>
are valid names, while<foo>
and<foo_bar>
are not. This requirement is so the HTML parser can distinguish custom elements from regular elements. - It also ensures forward compatibility when new tags are added to HTML. You can’t register the same tag more than once.
- Custom elements cannot be self-closing because HTML only allows a few elements to be self-closing. Always write a closing tag (
<app-drawer></app-drawer>
).
Extending Components
You can use inheritence while creating components. For example, if you want to create a UserCard
for two different types of users, you can first create a generic UserCard and extend it in the two specialized user cards. For more info on inheritence in components, refer to this Google web developers’ article.
Lifecycle Callbacks
We created connectedCallback
, which is automatically called when our element gets attached to the DOM. We also have disconnectedCallback
that gets called when our element gets removed from the DOM. attributesChangedCallback(attribute, oldval, newval)
is called when we change an attribute of a custom element.
Elements are instances of classes
Since elements are instances of classes, you can define public methods on these classes that can be used to allow other custom elements/scripts to interact with these elements rather than changing their attributes.
Defining private methods
You can define private methods many different ways. I prefer using IIFEs, as they are easy to write and understand. For example, if you are creating a component that has very complex internal workings, you could do something like:
(function() {
// Define private functions here with first argument as self
// When calling these functions, pass this from the class
// This is a way you can use private functions in JS
function _privateFunc(self, otherArgs) { ... }
// Now this is available only in this scope and can be used by your class here:
class MyComponent extends HTMLElement {
...
// Define functions like this that are accessible to interact with this element.
doSomething() {
...
_privateFunc(this, args)
}
...
}
customElements.define('my-component', MyComponent);
})()
Freeze Class definitions
Freeze your class definitions to prevent new properties from being added to it. Preventing existing properties from being removed and preventing existing properties, or their enumerability, configurability, or writability from being changed, also prevents the prototype from being changed. You can do this using:
class MyComponent extends HTMLElement { ... }
const FrozenMyComponent = Object.freeze(MyComponent);
customElements.define('my-component', FrozenMyComponent);
Note: Freezing class definitions will prevent you from monkey patching and can make your code difficult to debug.
Conclusion
The tutorials out there on Web Components are very limited. This can be blamed partly on React, which has mostly shadowed Web Components. I hope this article gives you enough information to go and build your own custom components without any dependencies. You can check out the Custom components API spec for more info on Web Components.
We’ve barely scratched the surface of Web Components in this article. If you want me to write more tutorials on Web Components, feel free to contact me.
You can read the part of this tutorial: HTML Web Component using Vanilla JS - Part 2!
Update
I’ve been repeatedly told not to use and promote the use of HTML imports as they will most probably removed/replaced with something else. So here is another approach as suggested by Yuri Karadzhov to keep the HTML in its own file and fetch it in the JS.
Remove the script
tag from the HTML file and <link rel="import" href="...">
statement from index.html. Since we’re fetching the HTML as a string and parsing it to template
tag directly, we dont need to define the currentDocument
which we were using to select the template earlier. You can rewrite your UserCard component as follows:
(async () => {
const res = await fetch('/UserCard/UserCard.html');
const textTemplate = await res.text();
// Parse and select the template tag here instead
// of adding it using innerHTML to avoid repeated parsing
// and searching whenever a new instance of the component is added.
const HTMLTemplate = new DOMParser().parseFromString(textTemplate, 'text/html')
.querySelector('template');
class UserCard extends HTMLElement {
constructor() { ... }
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
// Clone the template and the cloned node to the shadowDOM's root.
const instance = HTMLTemplate.content.cloneNode(true);
shadowRoot.appendChild(instance);
const userId = this.getAttribute('user-id');
//...
}
render(userData) { ... }
toggleCard() { ... }
}
customElements.define('user-card', UserCard);
})();