I will demonstrate writing a web component by implementing tabbed panels. The finished tabs will look like below. You can find the source code in this repository.

Web Component is a standard built into the browser. At the time of writing every major browser supports this feature. It is an underrated feature and often shadowed by popular SPA frameworks like React and Angular. I say this feature is underrated because WC (Web Component) predates React and it does not require importing any external libraries. Enough of history lets see how to write a component.

A WC needs two steps.

  1. A class that extends HTMLElement.
  2. Registering the component as a custom element.
<!DOCTYPE html>
<html>
<head>
  <script>
    class WCTab extends HTMLElement { } //Step 1
    customElements.define("wc-tab", WCTab) //Step 2
  </script>
</head>
</html>

That's it. A Web Component is ready to use. In registering the WC, the name must always contain a hyphen that is the reason it is wc-tab instead of wctab. This name is what needed to use this WC. We can use it just be creating a tag with same name as below.

<body>
  <wc-tab></wc-tab>
</body>

Opening the html in browser doesn't show anything. It is not any better than an empty div at this point. Lets write something in between the opening and close tag.

<wc-tab>
  <p>Hello world!</p>
</wc-tab>

This actually prints Hello world! in the browser!

Shadow Root

You almost always should enable shadow root in your WC. Shadow root provides scoped DOM tree with the web component as its root element. This enables us to import css styles without polluting the global scope. That means we can use css stylesheets and those styles will apply only within this custom element. Any tag with matching css selectors outside the custom component are unaffected. This can be enabled in our constructor as below.

class WCTab extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
  }
}

As soon as this change is made, the hello world printed in the browser has disappeared. When shadow DOM is attached, it replaces our existing children. WC has few lifecycle callbacks, one of them is connectedCallback. It is called as soon as the WC is attached to dom. Lets add it!

class WCTab extends HTMLElement {
  constructor() {
      super();
      this.shadow = this.attachShadow({ mode: "open" });
  }
  connectedCallback(){
    console.log("connected!");
  }
}

This prints connected! in console when the page is refreshed.

Tab - Example

Lets define how our tab component is going to be designed. Our WC will have each tab as div. The WC should define tab and its content as shown below.

<wc-tab>
  <div name="Tab 1">Tab 1 content</div>
  <div name="Tab 2">Tab 2 content</div>
  <div name="Tab 3">Tab 3 content</div>
</wc-tab>

We are going to read the provided children as input and generate a UI to show them as tabs. it is possible to make each tab as its own custom element instead of div tag. We will stick with div for this example. Let's see how to access the children in our component. We are going to do this in our lifecycle method connectedCallback

connectedCallback(){
  let tabs = this.querySelectorAll("div");
  console.log(tabs);
}

This is how we read the children. Unfortunately this does not work. connectedCallback is called before the children are attached to DOM. There is no simple way to read them as soon as they are attached. We go with MutationObserver. This observes changes for children and calls the given callback.

connectedCallback() {
  let thisNode = this;
  let observer = new MutationObserver(function () {
    let tabs = thisNode.querySelectorAll("div");
    console.log(tabs);
  });
  
  // We are only interested in the children of
  // this component
  observer.observe(this, { childList: true });
}

Now this prints NodeList(3) [div, div, div]. Those three divs are the three tabs we need to work. lets add a render method to generate the UI.

connectedCallback() {
  let thisNode = this;
  let observer = new MutationObserver(function () {
    thisNode.render();
  });

  // We are only interested in the children of
  // this component
  observer.observe(this, { childList: true });
}
render() {
  let tabs = this.querySelectorAll("div");
  // Generate UI
}

Now we separated the render logic from the lifecycle method, lets write UI.

render() {
  // Fetch the children as input
  let tabs = this.querySelectorAll("div");

  // Define basic structure
  this.shadowRoot.innerHTML = `
  <div class='tab-btn-container'></div>
  <div class='tab-panel-container'></div>
  `;
  let btnContainer = this.shadowRoot.querySelector(".tab-btn-container");
  let panelContainer = this.shadowRoot.querySelector(".tab-panel-container");

  for (let index = 0; index < tabs.length; index++) {
    let currentTab = tabs[index];
    this.addTab(currentTab, btnContainer, panelContainer)
  }
}

/**
* @param {HTMLElement} tab
* @param {HTMLElement} btnContainer
* @param {HTMLElement} panelContainer
*/
addTab(tab, btnContainer, panelContainer) {
  let tabBtn = document.createElement("button");
  let clonedTab = tab.cloneNode(true);
  let thisNode = this;
  let tabName = tab.getAttribute("name");
  tabBtn.textContent = tabName;
  tabBtn.setAttribute("name", tabName);
  btnContainer.appendChild(tabBtn);
  panelContainer.appendChild(clonedTab);
}

Note this.shadowRoot is used to access the shadow DOM. It is available in all custom components.

Next, we implement the selection state. At any time only one tab is active. Lets add a method to mark a tab active.

/**
* @param {String} tabName
*/
activate(tabName) {
  // Deactivate previously active tab if any
  let activeBtn = this.shadowRoot.querySelector(".tab-btn-container > button.active");
  if (activeBtn !== null) {
    activeBtn.classList.remove("active");
  }
  let activeTab = this.shadowRoot.querySelector(".tab-panel-container > div.active");
  if (activeTab !== null) {
    activeTab.classList.remove("active");
  }

  // Mark provided tab as active
  this.shadowRoot
    .querySelector(`.tab-btn-container > button[name='${tabName}']`)
    .classList.add("active");

  this.shadowRoot
    .querySelector(`.tab-panel-container > div[name='${tabName}']`)
    .classList.add("active");
}

This method activates a tab my adding a class active to it. This has to be triggered when the tab button is clicked. This is done as below.

tabBtn.addEventListener("click", function () {
  thisNode.activate(tabName);
})

Now we have interaction with our component, lets style it. Shadow DOM do not have a head tag, so we can directly attach style tag or link tag with stylesheets in the shadowRoot.

generateStyle() {
  let style = document.createElement("style");
  style.textContent =
    `
  *{
    background-color: #13005A;
    color: white;
    font-size: 2rem;
    font-family: sans-serif;
  }
  .tab-panel-container{
    padding: 8px;
  }
  .tab-btn-container{
    border-top-left-radius: 8px;
    border-top-right-radius: 8px;
  }
  .tab-panel-container > div {
    display: none;
  }
  .tab-panel-container > div.active{
    display: block;
  }
  .tab-btn-container{
    display: flex;
    gap: 8px;
  }
  .tab-btn-container > button{
    background-color: #4e6183;
    border: none;
    outline: none;
    color: white;
    padding: 4px 8px;
    border-radius: 8px;
    cursor: pointer;
  }
  .tab-btn-container > button.active{
    background-color: #03C988;
  }
  `;
  return style;
}

Style is attached the same way as any other element.

  this.shadowRoot.appendChild(this.generateStyle())

Thats it. The tab component is ready. There are some concepts not used here worth mentioning are custom attribute, template and slots. Use only whatever required for the components.