Sitemap
Charles

Charles

The Ultimate Guide to Web Components

6 min readJul 23, 2025

--

Press enter or click to view image in full size
Photo by Arnold Francisca on Unsplash

If you ever thought of creating you own HTML tag, then you are in the right place. If you prefer videos, I made a tutorial here.

What are web components?

Web components are part of the web APIs that allow us to create reusable custom HTML.

In this article, we will learn about:

  • Web components (duh…obviously)
  • Shadow dom
  • Using web components as building blocks
  • A lot more

So, how do we do that? I am glad you asked.

Start by creating a new Vanilla project using Vite. I show you how to do that here.

1. Basic Component

In your newly created document, go to the “main.js” file and remove everything.

Now type this:

class CustomElement extends HTMLElement{
constructor(){
super();
this.innerHTML = `<h1>Hello World</h1>`
}
}

Below the above type this:

customElements.define('custom-elem', CustomElement)

Take note of the arguments above. The first one is what you want to name your custom element. The second one is the name of the class you created earlier.

In our “index.html” file type this:

<custom-elem></custom-elem>

Your HTML code should look something like this:

<div id="app"></div>
<custom-elem ></custom-elem>

You should see this in your browser.

2. Encapsulating styles

This is where we introduce the concept of the shadow DOM.

Below “super()” that we typed earlier, type this:

this.attachShadow({mode:'open'});

Modify your previous “innerHTML” line to this:

this.shadowRoot.innerHTML = `<h1>Hello World</h1>`

Nothing should change in your browser. Now let’s modify the above line by adding styles. It will now look like this:

this.shadowRoot.innerHTML = `
<style>
h1{
color: aqua;
}
</style>
<h1>Hello World</h1>

In your browser, you should now see this:

Yaay, we changed the colour!!!

3. Web components’ life cycles

Connected Callback

This component is triggered when we add this custom element to the DOM.
Below the “constructor” you wrote earlier, type this:

connectedCallback(){
console.log("I am connected");
}

In your console, you should see “I am connected”.

Disconnected Callback

This is the opposite of the “connected”. This is triggered when we remove this custom element from the DOM.
Below the “connectedCallback” function type this:

disconnectedCallback(){
console.log("I am removed");
}

Attribute Changed callback

This lifecycle is triggered when we change an attribute of the web component.

Back in our “index.html” modify the custom element like this:

<custom-elem name="Femi"></custom-elem>

You will notice nothing has changed in our browser. This means we need to internally make the web component know there is an attribute.

In our “main.js” file, modify the “h1” line like this:

<h1>Hello ${this.getAttribute('name')}</h1>

You should now see the name reflected in the browser.

Get Charles’s stories in your inbox

Join Medium for free to get updates from this writer.

Now, to effect an external change, we do this. Under “disconnectedCallback” type this:

static get observedAttributes(){
return ['name']
}

In the above code, ‘name’ refers to the attribute you want to watch for changes. You can add more attributes to the array as you deem fit.

Now, outside the class you created, type this:

setInterval(()=>{
const customElement = document.querySelector("custom-elem");
customElement.setAttribute("name","Charles")
},2000)

Your code should look like this so far:

class CustomElement extends HTMLElement{
constructor(){
super();
this.attachShadow({mode:'open'});
this.shadowRoot.innerHTML = `
<style>
h1{
color: aqua;
}
</style>
<h1>Hello ${this.getAttribute('name')}</h1>
`
}


connectedCallback(){
console.log("I am connected");
}


disconnectedCallback(){
console.log("I am removed");
}


static get observedAttributes(){
return ['name']
}


attributeChangedCallback(observedAttribute, oldValue, newValue){
const theHeading = this.shadowRoot.querySelector("h1");


if(observedAttribute === 'name'){
theHeading.textContent = `Hello ${newValue}`
}
}
}


customElements.define('custom-elem', CustomElement);


setInterval(()=>{
const customElement = document.querySelector("custom-elem");
customElement.setAttribute("name","Charles")
},2000)

In your browser, the name you earlier put in should change after 2 seconds.

4. Adding JavaScript interactivity

This is a straightforward process. Back in our class below the ‘h1’ type this:

<button id="btn">Click</button>

Outside the backticks and beneath that, add this:

const btn = this.shadowRoot.getElementById("btn");


btn.addEventListener("click",()=>{
alert("Something was clicked");
})

The modification should look something like this:

this.shadowRoot.innerHTML = `
<style>
h1{
color: aqua;
}
</style>
<h1>Hello ${this.getAttribute('name')}</h1>
<button id="btn">Click</button>
`


const btn = this.shadowRoot.getElementById("btn");


btn.addEventListener("click",()=>{
alert("Something was clicked");
})

In the browser, you should now see a button. Clicking on the button should trigger the alert.

Press enter or click to view image in full size

5. Change the web components’ properties

Note this is properties and not attributes.

So why properties? This will make our custom elements dynamic and flexible.
This way, we can have external factors that will reflect inside the web components.

Back in our “main.js” file in the ‘innerHTML’ code, add this line:

<h2>First text</h2>

Below our ‘attributeChangedCallback’, add this:

set banana(val){
this.render(val)
}


render(val){
const heading = this.shadowRoot.querySelector("h2");
heading.textContent = val;
}

Note the ‘banana’ I used as an example. 😂😂😂’Banana’ represents the name of the property that would be changed externally.

Outside the class, and inside the ‘SetInterval’ we created earlier, add this:

customElement.banana = "Second Text"

In our browser, you should see the new tag change after a while.

Press enter or click to view image in full size

6. Using Web components as building blocks

Just like we can have components within other components in our various frameworks. Or the way we put elements in another element, we can do the same thing with web components.

In our “index.html” file, place a new tag inside the custom element. For me, it is:

<custom-elem name="Femi">
<h3>Hi Charles Angels</h3>
</custom-elem>

If we check the browser, there will be no change. Now, in our “main.js” file, we modify the innerHTML to look like this:

this.shadowRoot.innerHTML = `
<style>
h1{
color: aqua;
}
</style>
<h1>Hello ${this.getAttribute('name')}</h1>
<button id="btn">Click</button>
<h2>First text</h2>
<slot></slot>
`

Notice the now-added ‘slot’? Wherever they are placed, an inner element will appear. In our browser, we should now see this:

We are almost done.

7. Using templates

So far, we have been writing our HTML in the Class. This is a bit inconvenient. Thankfully, there is another way to do this.

In our “index.html” file, add this:

<template id="custom-template">
<style>
h1{
color: aqua;
}
</style>
<h1>Hello Femi</h1>
<button id="btn">Click</button>
<h2>First text</h2>
<slot></slot>
</template>

Note that templates never show in the browser. Like the name suggests, we use it as a template, and this template can be effectively used in the shadow DOM.

In the “main.js” file, we will change the constructor to look like this:

constructor(){
super();
const template = document.getElementById("custom-template");
const clone = template.content.cloneNode(true)
this.attachShadow({mode:'open'}).appendChild(clone);


const btn = this.shadowRoot.getElementById("btn");


btn.addEventListener("click",()=>{
alert("Something was clicked");
})
}

In the above code, we import the template, clone it, and then append it to the shadow DOM.

In the browser, the application should work the same way.

In all, your “main.js” should look like this:

class CustomElement extends HTMLElement{
constructor(){
super();
const template = document.getElementById("custom-template");
const clone = template.content.cloneNode(true)
this.attachShadow({mode:'open'}).appendChild(clone);

const btn = this.shadowRoot.getElementById("btn");

btn.addEventListener("click",()=>{
alert("Something was clicked");
})
}

connectedCallback(){
console.log("I am connected");
}

disconnectedCallback(){
console.log("I am removed");
}

static get observedAttributes(){
return ['name']
}

attributeChangedCallback(observedAttribute, oldValue, newValue){
const theHeading = this.shadowRoot.querySelector("h1");

if(observedAttribute === 'name'){
theHeading.textContent = `Hello ${newValue}`
}
}

set banana(val){
this.render(val)
}

render(val){
const heading = this.shadowRoot.querySelector("h2");
heading.textContent = val;
}
}

customElements.define('custom-elem', CustomElement);

setInterval(()=>{
const customElement = document.querySelector("custom-elem");
customElement.setAttribute("name","Charles");

customElement.banana = "Second Text"
},3000)

While your “index.html” should look like this:

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<custom-elem ></custom-elem>

<template id="custom-template">
<style>
h1{
color: aqua;
}
</style>
<h1>Hello Femi</h1>
<button id="btn">Click</button>
<h2>First text</h2>
<slot></slot>
</template>

<script type="module" src="/src/main.js"></script>
</body>
</html>

Whewwwwwww. We are finally at the end.🎉🎉🎉

If you learnt something new, please clap for the article and also share. Thank you so much. Until we meet in the next article.

--

--