Web Components in HTML: Custom Elements and Shadow DOM

Web Components let you build reusable HTML elements with their own behavior, structure, and styling boundaries. They are a native browser feature made up of custom elements, shadow DOM, and related APIs that help you create components without relying on a framework.

Quick answer: Web Components are a browser standard for creating reusable custom HTML tags. Use custom elements to define new tags, shadow DOM to encapsulate internals, and slots to project external content into the component.

Difficulty: Intermediate

You'll understand this better if you know: basic HTML structure, how DOM elements work, and how attributes and events behave in the browser.

1. What Are Web Components?

Web Components are a set of native browser APIs for creating reusable UI pieces. Instead of using only built-in tags like <button> or <input>, you can define your own elements such as <user-card> or <site-header>.

These features work together, but you can use them independently in some cases. Many developers use the phrase "Web Components" to mean the whole set, not just one API.

2. Why Web Components Matter

Web Components matter because they solve a common problem: how to build reusable interface pieces that behave consistently across a large site or application.

Without components, teams often repeat HTML, CSS, and JavaScript across pages. Web Components help you package structure and behavior into a single element that you can drop into any page that supports the browser APIs.

They are especially useful when you want:

They are less useful when you need a complete app architecture by themselves. Web Components do not replace routing, data fetching, state management, or design systems on their own.

3. Basic Syntax or Core Idea

A custom element is usually created by extending HTMLElement and registering the class with customElements.define(). After that, the browser recognizes the new tag.

Minimal custom element

The example below defines a simple element that renders text when it is added to the page.

class HelloBadge extends HTMLElement {
  connectedCallback() {
    this.textContent = "Hello from a custom element";
  }
}

customElements.define("hello-badge", HelloBadge);

This code creates a new HTML element called <hello-badge>. When the browser inserts it into the document, connectedCallback() runs and updates the content.

Using the element in HTML

Once registered, you can use the custom tag like any other HTML element.

<hello-badge></hello-badge>

The browser treats the tag as a real DOM element once the definition is loaded.

Adding a shadow root

Shadow DOM creates a private subtree inside the component. That subtree is separate from the main document DOM.

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        p { font-weight: 600; }
      </style>
      <p>User profile card</p>
    `;
  }
}

customElements.define("user-card", UserCard);

This version keeps the component markup and styles inside the shadow root, which reduces style collisions with the rest of the page.

4. Step-by-Step Examples

Example 1: Reading attributes in a custom element

Attributes are a common way to configure a component from HTML. Here the component reads a name attribute and renders a greeting.

class GreetingCard extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute("name") || "guest";
    this.textContent = `Welcome, ${name}!`;
  }
}

customElements.define("greeting-card", GreetingCard);

You can use it like this:

<greeting-card name="Mina"></greeting-card>

This pattern is useful when a component needs simple configuration from markup.

Example 2: Observing attribute changes

If an attribute can change after the element is created, use observedAttributes and attributeChangedCallback().

class StatusTag extends HTMLElement {
  static get observedAttributes() {
    return ["status"];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "status" && oldValue !== newValue) {
      this.render();
    }
  }

  render() {
    const status = this.getAttribute("status") || "offline";
    this.textContent = `Status: ${status}`;
  }
}

customElements.define("status-tag", StatusTag);

This example shows how to react when external HTML changes the component's configuration.

Example 3: Shadow DOM with slots

Slots let the outside page supply content while the component keeps its internal structure and styling.

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <section>
        <h2><slot name="title">Default title</slot></h2>
        <div><slot>Default content</slot></div>
      </section>
    `;
  }
}

customElements.define("info-panel", InfoPanel);

Use it like this:

<info-panel>
  <span slot="title">Shipping details</span>
  <p>Orders usually arrive within 3 to 5 business days.</p>
</info-panel>

The slot keeps the component reusable while still allowing page-specific content.

Example 4: Lifecycle cleanup

Components can add timers, listeners, or observers. If they do, they should also clean up when removed from the page.

class LiveClock extends HTMLElement {
  constructor() {
    super();
    this.timerId = 0;
  }

  connectedCallback() {
    this.render();
    this.timerId = window.setInterval(() => this.render(), 1000);
  }

  disconnectedCallback() {
    window.clearInterval(this.timerId);
  }

  render() {
    this.textContent = new Date().toLocaleTimeString();
  }
}

customElements.define("live-clock", LiveClock);

This pattern prevents timers from continuing to run after the component leaves the document.

5. Practical Use Cases

Web Components are a good fit for many real projects, especially when you want stable, reusable UI parts.

They are also useful when a team wants browser-native encapsulation instead of a framework-specific component model.

6. Common Mistakes

Mistake 1: Using a custom element before it is defined

Custom elements must be registered before the browser can upgrade them. If the script runs too late, the tag may appear uninitialized for a moment or stay plain until definition loads.

Problem: The element appears in the page, but its class never runs because the registration code is missing, delayed, or uses the wrong tag name.

<profile-card></profile-card>

// The class exists, but no registration happens.
class ProfileCard extends HTMLElement {}

Fix: Register the element with the exact hyphenated name.

class ProfileCard extends HTMLElement {}

customElements.define("profile-card", ProfileCard);

<profile-card></profile-card>

The corrected version works because the browser can associate the tag with its class.

Mistake 2: Forgetting the hyphen in the tag name

Custom element names must contain a hyphen. This rule avoids conflicts with future HTML tags.

Problem: Defining a tag name like card causes a DOM exception because it is not a valid custom element name.

class CardBox extends HTMLElement {}

customElements.define("card", CardBox);

Fix: Use a name with at least one hyphen, such as card-box.

class CardBox extends HTMLElement {}

customElements.define("card-box", CardBox);

The corrected version works because the browser accepts hyphenated custom element names.

Mistake 3: Editing shadow DOM from outside the component

Shadow DOM is designed to encapsulate internal structure. Trying to query internal nodes from the main document usually fails.

Problem: Code outside the component cannot find elements inside its shadow root with ordinary document queries.

const title = document.querySelector("user-card h2");
// title is null if h2 is inside shadow DOM

Fix: Expose an API through attributes, properties, or custom events instead of reaching into the shadow root from the outside.

class UserCard extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = "<h2>User profile</h2>";
  }
}

The corrected approach respects encapsulation and keeps the component easier to maintain.

Mistake 4: Attaching shadow DOM repeatedly

A shadow root can only be attached once to a given element. Calling attachShadow() again on the same host causes an error.

Problem: Reattaching shadow DOM to the same element can throw a DOM exception and break rendering.

class PanelBox extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: "open" });
    this.attachShadow({ mode: "open" });
  }
}

Fix: Create the shadow root once, usually in the constructor, and reuse it.

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

The corrected version works because the shadow root is created only once per instance.

7. Best Practices

Practice 1: Prefer attributes for simple configuration

Use attributes for values that should be visible in HTML and easy to author in templates. This keeps components predictable and easier to use.

// Good: the component reads a simple text attribute.
<alert-box tone="warning"></alert-box>

Attributes are a strong fit for labels, modes, IDs, and other small configuration values.

Practice 2: Clean up listeners and timers

If a component starts intervals, subscriptions, or observers, remove them in disconnectedCallback(). Otherwise, hidden work may continue after the element is removed.

class TickerBadge extends HTMLElement {
  constructor() {
    super();
    this.timerId = 0;
  }

  connectedCallback() {
    this.timerId = window.setInterval(() => this.textContent = "tick", 1000);
  }

  disconnectedCallback() {
    window.clearInterval(this.timerId);
  }
}

This keeps your component efficient and avoids leaks.

Practice 3: Use shadow DOM when encapsulation helps

Shadow DOM is useful when your component needs a private structure and styles. If you want page CSS to style every internal part freely, shadow DOM may be the wrong fit.

// Encapsulated internals that should not be styled accidentally from outside.
this.attachShadow({ mode: "open" });

This choice reduces CSS collisions and keeps component behavior more predictable across a large app.

8. Limitations and Edge Cases

Another practical edge case is accessibility: encapsulation does not remove the need for semantic HTML, proper labeling, and keyboard support. A custom element still needs to behave like a good native control if it replaces one.

9. Practical Mini Project

Here is a small but complete component: a dismissible notification box with a title slot and a message slot. It uses shadow DOM for structure and a button for closing.

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; border: 1px solid #ccc; padding: 1rem; border-radius: 0.5rem; }
        button { margin-top: 1rem; }
      </style>
      <article>
        <header><slot name="title">Notice</slot></header>
        <div><slot>Default message</slot></div>
        <button type="button">Dismiss</button>
      </article>
    `;

    const button = this.shadowRoot.querySelector("button");
    button.addEventListener("click", () => this.remove());
  }
}

customElements.define("notice-box", NoticeBox);

Use it in HTML like this:

<notice-box>
  <span slot="title">Saved</span>
  Your profile changes have been saved successfully.
</notice-box>

This mini project combines registration, shadow DOM, slots, internal styling, and a small interaction in one reusable component.

10. Key Points

11. Practice Exercise

Create a custom element called <product-pill> that displays a product name and category. The category should come from an attribute, and the text should update if the attribute changes.

One possible solution is shown below.

class ProductPill extends HTMLElement {
  static get observedAttributes() {
    return ["category"];
  }

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "category" && oldValue !== newValue) {
      this.render();
    }
  }

  render() {
    const productName = this.getAttribute("name") || "Unnamed product";
    const category = this.getAttribute("category") || "General";

    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-block; padding: 0.5rem 0.75rem; border-radius: 999px; background: #eef; }
      </style>
      <span>Product: ${productName} — Category: ${category}</span>
    `;
  }
}

customElements.define("product-pill", ProductPill);

This solution works because it combines the element lifecycle with attribute observation and a shadow root.

12. Final Summary

Web Components give you a native way to build reusable HTML elements with clear boundaries around markup, behavior, and styling. Custom elements provide the component definition, shadow DOM provides encapsulation, and slots let outside content flow into the component in a controlled way.

For small UI parts, shared design-system controls, or embeddable widgets, they can be a strong choice because they are standards-based and framework-agnostic. The main things to remember are to register the element correctly, observe attributes when configuration can change, and clean up any work the component starts.

If you want to go further, the next useful topics are shadow DOM styling patterns, form-associated custom elements, and how Web Components integrate with your preferred framework.