Web Components: Custom Elements and Shadow DOM Explained

Why This Matters

In a recent project, I used Vue to create Custom Elements, which differs significantly from the traditional single Vue instance rendered into a DOM node. This sparked my deep dive into the topic.

Creating a Custom Element

Let's start with the basics: how to define a custom element. Suppose we want a drawer component:

class MyDrawer extends HTMLElement { /* ... */ }
window.customElements.define('my-drawer', MyDrawer);

Here, MyDrawer extends HTMLElement. Registering it with customElements.define gives it the tag <my-drawer>. We can now create and append it:

const drawer = document.createElement('my-drawer');
document.body.appendChild(drawer);

But our element does nothing yet. To make it functional, we need to implement lifecycle callbacks: constructor, connectedCallback, disconnectedCallback, attributeChangedCallback.

Below is an enhanced version that supports an open attribute to show/hide:

class MyDrawer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  static get observedAttributes() {
    return ['open'];
  }

  get open() {
    return this.hasAttribute('open');
  }

  set open(val) {
    if (val) {
      this.setAttribute('open', '');
    } else {
      this.removeAttribute('open');
    }
    this.updateVisibility();
  }

  updateVisibility() {
    const content = this.shadowRoot.querySelector('.content');
    if (content) {
      content.style.display = this.open ? 'block' : 'none';
    }
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .content { display: none; padding: 10px; border: 1px solid #ccc; }
      </style>
      <div class="content">Drawer Content</div>
    `;
    this.updateVisibility();
  }

  disconnectedCallback() {
    // cleanup if needed
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'open') {
      this.updateVisibility();
    }
  }
}

Note the observedAttributes static getter: only attributes listed here trigger attributeChangedCallback. Attributes like class or style won't fire it.

Now we have show/hide functionality, but we still lack style isolation and slot support. That's where Shadow DOM comes in.

Shadow DOM: Style Isolation and Slots

Shadow DOM's primary benefit is encapuslation. In large frontend projects, CSS conflicts are common. Community solutions like CSS Modules or scoped styles address this, but Shadow DOM is the native web standard.

Elemetns inside a Shadow DOM are unaffected by external styles, and Shadow DOM styles don't leak out. Additionally, Shadow DOM supports <slot> elements for composition, similar to Vue's slots.

In the code above, we already used attachShadow({ mode: 'open' }). The shadow tree is created, and we set its HTML in connectedCallback. The styles inside the <style> block are scoped to that shadow tree only.

To use slots, modify the template:

this.shadowRoot.innerHTML = `
  <style>
    :host { display: block; }
    .wrapper { border: 1px solid #aaa; padding: 10px; }
    .header { font-weight: bold; }
    ::slotted(*) { margin: 5px; }
  </style>
  <div class="wrapper">
    <div class="header"><slot name="title"></slot></div>
    <div><slot></slot></div>
  </div>
`;

Then users can project content:

<my-drawer>
  <span slot="title">My Title</span>
  <p>This is the main content.</p>
</my-drawer>

This provides a powerful, standardised way to build reusable components.

Further Reading

Tags: custom-elements shadow-dom web-components javascript html

Posted on Sun, 18 Oct 2026 16:53:42 +0000 by tkmk