JavaScript Web Components: Custom Elements, Shadow DOM, and Slots

Web Components let you build reusable browser-native UI elements with vanilla JavaScript and standard web APIs. This article explains how custom elements, Shadow DOM, and slots work together, when to use them, and how to avoid the most common mistakes.

Quick answer: Web Components are a set of browser features for creating custom HTML elements with their own behavior, styling, and content insertion points. They are ideal when you want reusable UI with strong encapsulation and no framework dependency.

Difficulty: Intermediate

You'll understand this better if you know: basic HTML elements, DOM manipulation, and how JavaScript classes work.

1. What Is Web Components?

Web Components are browser standards that let you create your own HTML tags and package their behavior, structure, and styling into reusable units. Instead of relying on a framework component model, you define native components that work directly in the browser.

These features are independent, but they are often used together to create complete components.

2. Why Web Components Matter

Web Components solve a common problem: many apps need reusable UI pieces that behave consistently across pages, teams, or projects. They help you avoid copy-pasting markup and styles while keeping component logic in one place.

They are especially useful when you want the component to work in plain HTML, in a static site, or alongside different frameworks. Since they are web standards, the same component can often be reused across multiple codebases.

Web Components are not always the best choice for every project. If your app is small or the UI is simple, native HTML plus a little JavaScript may be enough. If you need a large ecosystem of prebuilt widgets and app-level patterns, a framework may still be more productive.

3. Basic Syntax or Core Idea

Define a custom element

A custom element is usually created by extending HTMLElement and registering the class with customElements.define().

class UserCard extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<p>Hello from a custom element</p>`;
  }
}

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

This code creates a new HTML tag named <user-card>. When the element appears in the document, connectedCallback() runs and fills it with content.

Use the custom element in HTML

After registration, you can use the element like any other tag.

<user-card></user-card>

The browser treats it as a real element, not a plain div with a special class name.

Attach Shadow DOM

Shadow DOM gives the component its own private DOM tree and style boundary. This keeps component styles from leaking out and outside styles from accidentally affecting the component.

class UserCard extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<style>p { color: rebeccapurple; }</style><p>Shadow content</p>`;
  }
}

The component now renders inside its own shadow tree, with styles scoped to that tree.

4. Step-by-Step Examples

Example 1: A simple greeting component

This example shows the smallest useful custom element. It renders text based on an attribute.

class GreetingTag extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute('name') ?? 'friend';
    this.textContent = `Hello, ${name}!`;
  }
}

customElements.define('greeting-tag', GreetingTag);

Use it like this: <greeting-tag name="Ava"></greeting-tag>. The component reads its attribute and updates its own text.

Example 2: Rendering with Shadow DOM

This example adds encapsulation so the component can own its styles.

class BadgeLabel extends HTMLElement {
  constructor() {
    super();
    this.shadowRoot ??= this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `style
      span { background: #222; color: white; padding: 0.25rem 0.5rem; border-radius: 999px; }
    </style>
    <span>New</span>`;
  }
}

customElements.define('badge-label', BadgeLabel);

Now the badge styles are isolated from the rest of the page, which reduces CSS conflicts.

Example 3: Reacting to attribute changes

Many components need to update when attributes change. Use observedAttributes and attributeChangedCallback().

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

  connectedCallback() {
    this.render();
  }

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

  render() {
    this.textContent = `${Number(this.getAttribute('value') ?? 0).toFixed(2)}`;
  }
}

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

This component updates whenever its value attribute changes, so it stays in sync with the DOM.

Example 4: Passing content with slots

Slots let consumers place custom content inside a component without breaking encapsulation.

class PanelBox extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<style>
      .box { border: 1px solid #ccc; padding: 1rem; border-radius: 0.5rem; }
      header { font-weight: 600; margin-bottom: 0.5rem; }
    </style>
    <div class="box">
      <header><slot name="title">Default title</slot></header>
      <slot>Default body</slot>
    </div>`;
  }
}

customElements.define('panel-box', PanelBox);

Consumers can now write title and body content in the light DOM, and the component decides where each piece appears.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Defining the custom element too late

If the browser sees the element before it is registered, it may render as an unknown tag until the definition loads. This often happens when the script runs after the page content without upgrading existing elements in time.

Problem: The element exists in the HTML, but the browser does not yet know how to construct it because customElements.define() has not run.

<user-card></user-card>

class UserCard extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Loaded';
  }
}

Fix: Register the element before you expect it to appear, or use a deferred module script so the definition loads consistently.

class UserCard extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Loaded';
  }
}

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

<user-card></user-card>

The corrected version works because the browser can upgrade the element as soon as it encounters the registered tag.

Mistake 2: Calling attachShadow() more than once

A shadow root can be attached only once per element. Reattaching it causes a runtime error and breaks initialization logic.

Problem: Calling attachShadow() repeatedly throws because the element already has a shadow tree.

class InfoBox extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.attachShadow({ mode: 'open' });
  }
}

Fix: Create the shadow root once, usually in the constructor, and reuse the stored reference afterward.

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

The fixed version avoids the shadow-root error by keeping the component initialization one-time only.

Mistake 3: Forgetting that attributes are strings

Attributes look like typed values, but the DOM stores them as strings. If you use them in arithmetic without conversion, you can get unexpected results.

Problem: The count attribute is a string, so adding 1 can concatenate instead of incrementing.

class CounterBadge extends HTMLElement {
  connectedCallback() {
    const count = this.getAttribute('count');
    this.textContent = count + 1;
  }
}

Fix: Convert the attribute to a number before using it as one.

class CounterBadge extends HTMLElement {
  connectedCallback() {
    const count = Number(this.getAttribute('count') ?? 0);
    this.textContent = String(count + 1);
  }
}

The fixed version works because the component explicitly converts the attribute before using arithmetic.

7. Best Practices

Practice 1: Use the constructor for one-time setup

Attach the shadow root and prepare permanent state in the constructor, then use lifecycle callbacks for DOM-dependent work.

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

This pattern makes initialization predictable and avoids accidental re-creation during reconnection.

Practice 2: Reflect only the attributes that matter

Not every internal state value should become an attribute. Expose only the parts that users of the component need to control from HTML.

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

Keeping the public API small makes the component easier to document and less fragile.

Practice 3: Prefer slots over string concatenation for user content

Slots let callers pass markup safely and keep the component flexible. They are usually better than manually injecting HTML strings for content that belongs to the caller.

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

Using slots keeps content ownership clear and reduces the risk of breaking markup structure.

8. Limitations and Edge Cases

One common issue is assuming that Shadow DOM makes a component completely invisible to the outside world. Events still bubble, and consumers can interact with the component through its public API, attributes, and custom events.

9. Practical Mini Project

Let’s build a small reusable notification card that can show a title, body, and action area. It uses Shadow DOM for styling and slots for flexible content.

class NoticeCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `<style>
      .card { border: 1px solid #d0d7de; border-radius: 0.75rem; padding: 1rem; font-family: system-ui, sans-serif; }
      .title { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.5rem; }
      .body { margin: 0 0 0.75rem; }
      .actions { display: flex; gap: 0.5rem; }
    </style>
    <div class="card">
      <div class="title"><slot name="title">Notice</slot></div>
      <div class="body"><slot>Default message</slot></div>
      <div class="actions"><slot name="actions"></slot></div>
    </div>`;
  }
}

customElements.define('notice-card', NoticeCard);

You can use it like this:

<notice-card>
  <span slot="title">Server status</span>
  The deployment finished successfully.
  <button slot="actions">View details</button>
</notice-card>

This project shows how Web Components combine encapsulated styling with flexible consumer content. The same component can be dropped into many pages without duplicating markup.

10. Key Points

11. Practice Exercise

Create a reusable <temperature-chip> component with these requirements:

Expected output: a small chip such as 18°C cool or 24°C warm.

Hint: Use observedAttributes, convert the attribute to a number, and rerender from a shared method.

Solution:

class TemperatureChip extends HTMLElement {
  static get observedAttributes() {
    return ['value'];
  }

  constructor() {
    super();
    this.shadowRootRef = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    const temp = Number(this.getAttribute('value') ?? 0);
    const label = temp >= 20 ? 'warm' : 'cool';

    this.shadowRootRef.innerHTML = `<style>
      .chip { display: inline-block; padding: 0.35rem 0.7rem; border-radius: 999px; background: #f3f4f6; font-family: system-ui, sans-serif; }
    </style>
    <span class="chip">${temp}°C ${label}</span>`;
  }
}

customElements.define('temperature-chip', TemperatureChip);

This solution meets all four requirements: it creates a custom tag, keeps styles isolated, reacts to changes, and converts the attribute to a number before using it.

12. Final Summary

Web Components are a browser-native way to build reusable UI with standard JavaScript, HTML, and CSS. The core pieces are custom elements for behavior, Shadow DOM for encapsulation, and slots for flexible content projection.

They are a strong choice when you want components that work outside a specific framework, especially for design systems, cross-project widgets, and long-lived UI primitives. The main things to remember are to register elements correctly, manage shadow roots carefully, and treat attributes as string inputs that often need conversion.

If you want to keep learning, the best next step is to study custom element lifecycle callbacks in more depth and then build a small component library of your own.