HTML Custom Elements & Shadow DOM: Build Encapsulated Components

Custom Elements and Shadow DOM let you build reusable HTML components with their own behavior, structure, and styling boundaries. They are the foundation of Web Components and are useful when you want native browser components that work across a project without depending on a framework.

Quick answer: Custom Elements let you define new HTML tags with JavaScript, while Shadow DOM lets you hide and protect a component’s internal markup and styles. Use them together to create encapsulated, accessible UI components.

Difficulty: Intermediate

You'll understand this better if you know: basic HTML elements, how attributes work, and simple DOM concepts like parent, child, and event bubbling.

1. What Is HTML Custom Elements & Shadow DOM?

Custom Elements are a browser feature that lets you create your own HTML elements, such as <user-card> or <rating-stars>. Shadow DOM is a way to attach a private DOM subtree to an element so its internal structure and styles are isolated from the page.

These features are often used together, but they solve different problems. Custom Elements give you the component API; Shadow DOM gives you the internal boundary.

2. Why Custom Elements and Shadow DOM Matter

As projects grow, repeated markup becomes hard to maintain. Designers update one card style, but three other cards break because they share the same class names. Shadow DOM helps by keeping component styles local, and Custom Elements helps by giving that component a standard HTML tag and lifecycle.

They matter because they are built into the browser. You do not need a framework to get reusable components, and the components can still be used from plain HTML, server-rendered pages, or framework apps.

3. Basic Syntax or Core Idea

At the core, a custom element is a class that extends HTMLElement, then gets registered with the browser. If you want encapsulated internals, you attach a shadow root to the element and render inside it.

Defining a custom element

This is the minimum structure for creating a new element:

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

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

This registers the tag name <user-card>. When the browser creates or upgrades that element, it uses the class you defined.

Attaching Shadow DOM

To isolate internal markup and style, attach a shadow root and render into it:

class UserCard extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `<p>Encapsulated content</p>`;
  }
}

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

The element now owns a private rendering area. Outside CSS can still style the host element itself, but the shadow tree is isolated unless you expose parts or CSS custom properties.

4. Step-by-Step Examples

Example 1: A simple greeting component

Start with a small component that renders a message when inserted into the page. This shows the minimum custom element lifecycle.

class GreetingBox extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<p>Welcome to the page!</p>`;
  }
}

customElements.define("greeting-box", GreetingBox);

You can use it in HTML as <greeting-box></greeting-box>. The browser calls connectedCallback() when the element is attached to the document.

Example 2: A component with Shadow DOM

Now isolate the template and styles inside the component so outside CSS cannot easily break it.

class BadgeTag extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) return;

    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        p {
          padding: 0.25rem 0.5rem;
          border-radius: 999px;
          background: #eee;
          font: 600 0.875rem/1.2 sans-serif;
        }
      </style>
      <p><slot></slot></p>
    `;
  }
}

customElements.define("badge-tag", BadgeTag);

This component uses a slot so page content can pass through the shadow boundary. If you write <badge-tag>New</badge-tag>, the word New appears inside the internal paragraph.

Example 3: Responding to attributes

Attributes are one of the main ways to configure a custom element. Use observedAttributes and attributeChangedCallback() when the UI should react to attribute updates.

class StatusPill extends HTMLElement {
  static get observedAttributes() {
    return ["state"];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

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

customElements.define("status-pill", StatusPill);

Now <status-pill state="online"></status-pill> updates based on its attribute value. This pattern is common for component configuration.

Example 4: Extending a native HTML pattern with slots

Slots are useful when your component needs to wrap user-provided content but keep control of its layout.

class InfoCard extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) return;

    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <article>
        <header><slot name="title"></slot></header>
        <section><slot></slot></section>
      </article>
    `;
  }
}

customElements.define("info-card", InfoCard);

In markup, you can project named and default content separately. That keeps your component flexible without exposing its internal structure.

5. Practical Use Cases

Custom Elements and Shadow DOM are a strong fit for UI pieces that appear many times and need predictable behavior.

They are less useful when the component is tiny, disposable, or tightly tied to one page script. In those cases, simple semantic HTML plus classes may be easier to maintain.

6. Common Mistakes

Mistake 1: Defining the element but forgetting the hyphen

Custom element names must include a hyphen. This is part of the HTML standard so the browser can distinguish them from built-in tags.

Problem: The browser will reject a name like card because it is not a valid custom element name, so registration fails.

class CardBox extends HTMLElement {}

customElements.define("card", CardBox);

Fix: Use a hyphenated name such as card-box.

class CardBox extends HTMLElement {}

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

The corrected version works because the tag name now follows the custom element naming rules.

Mistake 2: Attaching Shadow DOM more than once

Shadow DOM should usually be attached once. If you call attachShadow() repeatedly, the browser can throw a NotSupportedError or otherwise prevent the component from rendering correctly.

Problem: Reattaching a shadow root inside a lifecycle callback can break the component because a host element can only have one shadow root.

class PanelBox extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `<p>Panel</p>`;
  }
}

Fix: Check whether a shadow root already exists before attaching a new one.

class PanelBox extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) return;

    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `<p>Panel</p>`;
  }
}

The corrected version avoids duplicate shadow roots and makes the element safe to reconnect.

Mistake 3: Expecting outside CSS to style shadow contents

Shadow DOM intentionally blocks most page-level CSS from reaching inside the component. This surprises beginners who try to style internal elements from a global stylesheet.

Problem: A selector like badge-tag p will not match the internal paragraph inside the shadow tree, so the style appears to do nothing.

/* Outside the component */
badge-tag p {
  color: red;
}

Fix: Style the shadow tree from inside the component, or expose controlled styling hooks such as CSS custom properties or part.

class BadgeTag extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) return;

    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        p { color: red; }
      </style>
      <p><slot></slot></p>
    `;
  }
}

The corrected version works because the styling is applied inside the same shadow boundary as the markup.

7. Best Practices

Practice 1: Render once, then update deliberately

Repeatedly replacing all markup in connectedCallback() can cause duplicated listeners, extra work, or flicker. Prefer a one-time render plus targeted updates.

class CounterBadge extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) return;
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `<span id="count">0</span>`;
  }
}

That pattern makes the component easier to reason about and safer to reconnect in the DOM.

Practice 2: Use attributes for public configuration

Attributes make your component easy to use from plain HTML and server-rendered templates. They also help keep your component API familiar.

class AlertBox extends HTMLElement {
  static get observedAttributes() {
    return ["type"];
  }
}

This is usually better than inventing custom properties in markup, because attributes are visible, declarative, and easy to validate.

Practice 3: Keep accessibility visible in the component API

If your component behaves like a button, dialog, or tab, it should use the correct roles, states, and keyboard behavior. Shadow DOM does not make accessibility automatic.

class ToggleSwitch extends HTMLElement {
  connectedCallback() {
    this.setAttribute("role", "switch");
    this.setAttribute("tabindex", "0");
  }
}

Accessible semantics should match the behavior users experience, not just the visual design.

8. Limitations and Edge Cases

A common “not working” report is “my component styles do nothing.” In many cases the issue is not CSS syntax; it is the Shadow DOM boundary preventing outside styles from entering the component.

9. Practical Mini Project

Here is a small but complete custom element that renders a profile card with Shadow DOM, accepts attributes, and uses a slot for flexible content.

class ProfileCard extends HTMLElement {
  static get observedAttributes() {
    return ["name", "role"];
  }

  connectedCallback() {
    if (this.shadowRoot) return;

    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        article {
          border: 1px solid #ccc;
          border-radius: 0.75rem;
          padding: 1rem;
          font-family: sans-serif;
          max-width: 20rem;
        }

        h2 {
          margin: 0 0 0.25rem;
          font-size: 1.1rem;
        }

        p {
          margin: 0 0 0.75rem;
          color: #555;
        }
      </style>
      <article>
        <h2 id="name"></h2>
        <p id="role"></p>
        <slot></slot>
      </article>
    `;

    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    if (!this.shadowRoot) return;

    const name = this.getAttribute("name") || "Unknown person";
    const role = this.getAttribute("role") || "No role set";

    this.shadowRoot.querySelector("#name").textContent = name;
    this.shadowRoot.querySelector("#role").textContent = role;
  }
}

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

Used in HTML, it looks like this:

<profile-card name="Amina" role="Frontend Engineer">
  <span>Building reusable UI components.</span>
</profile-card>

This mini project shows the usual component flow: register the element, render once into Shadow DOM, react to attribute changes, and expose flexible content with a slot.

10. Key Points

11. Practice Exercise

Create a custom element named <price-tag> that displays a currency amount and supports a value attribute. Make it render inside Shadow DOM, and use a slot or fallback text for optional label content.

class PriceTag extends HTMLElement {
  static get observedAttributes() {
    return ["value"];
  }

  connectedCallback() {
    if (this.shadowRoot) return;

    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        .price {
          display: inline-flex;
          gap: 0.5rem;
          align-items: baseline;
          padding: 0.35rem 0.6rem;
          border-radius: 0.5rem;
          background: #f5f5f5;
          font-family: sans-serif;
        }

        .label {
          font-weight: 600;
        }
      </style>
      <span class="price">
        <span class="label" id="label"></span>
        <span id="amount"></span>
      </span>
    `;

    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    if (!this.shadowRoot) return;

    const rawValue = this.getAttribute("value") || "0.00";
    const amount = `$${rawValue}`;
    const label = this.textContent.trim();

    this.shadowRoot.querySelector("#label").textContent = label || "Price";
    this.shadowRoot.querySelector("#amount").textContent = amount;
  }
}

customElements.define("price-tag", PriceTag);

This exercise reinforces the core ideas: define a tag, render inside Shadow DOM, and keep the public API small and clear.

12. Final Summary

Custom Elements give HTML a standard way to define your own reusable tags, while Shadow DOM keeps the component’s internal structure and styles encapsulated. Together, they make it possible to build self-contained UI pieces that are easy to reuse across pages and projects.

In practice, the most important habits are to register valid hyphenated names, attach Shadow DOM only once, manage attributes carefully, and design for accessibility from the start. If you want to go deeper, the next step is to learn slots, part, and lifecycle callbacks in more detail so you can build more flexible components without losing encapsulation.