Skip to content

Web Components: A Practical Introduction

How to build framework-agnostic web components with Custom Elements, Shadow DOM and HTML templates — reusable UI elements that work anywhere.

4 min read
Web component architecture diagram showing custom element, shadow DOM, and template composition

Web components let you create reusable UI elements that work in any framework — or no framework at all. They are built on three browser APIs: Custom Elements (define new HTML tags), Shadow DOM (encapsulated styles and markup), and HTML Templates (reusable markup fragments). Unlike React or Vue components, web components are a browser standard. A component built today will work in ten years without a build step.

The practical use case is design systems. If your organization has teams using React, Angular, and vanilla JavaScript, web components let you build shared UI elements once and use them everywhere.

Custom Elements

Custom Elements let you define new HTML tags with custom behavior. The browser treats them like native elements — they work in HTML, can be queried with document.querySelector, and fire events.

tstypescript
// Define a custom element
class UserCard extends HTMLElement {
  // Observed attributes trigger attributeChangedCallback
  static observedAttributes = ['name', 'email', 'avatar'];
 
  constructor() {
    super();
    // Attach shadow DOM for style encapsulation
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    // Called when element is added to the DOM
    this.render();
  }
 
  attributeChangedCallback(
    name: string,
    oldValue: string | null,
    newValue: string | null
  ) {
    // Called when an observed attribute changes
    if (oldValue !== newValue) {
      this.render();
    }
  }
 
  private render() {
    const name = this.getAttribute('name') ?? 'Unknown';
    const email = this.getAttribute('email') ?? '';
    const avatar = this.getAttribute('avatar') ?? '';
 
    this.shadowRoot!.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, sans-serif;
        }
        .card {
          display: flex;
          align-items: center;
          gap: 12px;
          padding: 16px;
          border: 1px solid #e2e8f0;
          border-radius: 8px;
        }
        .avatar {
          width: 48px;
          height: 48px;
          border-radius: 50%;
          object-fit: cover;
        }
        .name {
          font-weight: 600;
          font-size: 1rem;
        }
        .email {
          color: #64748b;
          font-size: 0.875rem;
        }
      </style>
      <div class="card">
        ${avatar ? `<img class="avatar" src="${this.escapeHtml(avatar)}" alt="${this.escapeHtml(name)}" />` : ''}
        <div>
          <div class="name">${this.escapeHtml(name)}</div>
          <div class="email">${this.escapeHtml(email)}</div>
        </div>
      </div>
    `;
  }
 
  private escapeHtml(text: string): string {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }
}
 
// Register the custom element
customElements.define('user-card', UserCard);
htmlhtml
<!-- Usage — works in any HTML page, any framework -->
<user-card
  name="Sarah Chen"
  email="sarah@example.com"
  avatar="/avatars/sarah.jpg"
></user-card>
 
<!-- Dynamically create in JavaScript -->
<script>
  const card = document.createElement('user-card');
  card.setAttribute('name', 'Alex Rivera');
  card.setAttribute('email', 'alex@example.com');
  document.body.appendChild(card);
</script>

Shadow DOM Encapsulation

Shadow DOM creates an isolated DOM tree inside your component. Styles defined inside the shadow root do not leak out, and external styles do not leak in. This is the core advantage over framework components — true style encapsulation at the browser level.

tstypescript
// ❌ Without Shadow DOM — styles leak
class BadButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <style>
        button { background: red; color: white; }
        /* This style affects ALL buttons on the page! */
      </style>
      <button>Click me</button>
    `;
  }
}
 
// ✅ With Shadow DOM — styles are encapsulated
class GoodButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    this.shadowRoot!.innerHTML = `
      <style>
        button {
          background: #3b82f6;
          color: white;
          border: none;
          padding: 8px 16px;
          border-radius: 6px;
          cursor: pointer;
          font-size: 0.875rem;
        }
        button:hover {
          background: #2563eb;
        }
        /* Only affects the button inside this shadow root */
      </style>
      <button><slot></slot></button>
    `;
  }
}
 
customElements.define('ui-button', GoodButton);
htmlhtml
<!-- Slot allows passing content from light DOM -->
<ui-button>Save Changes</ui-button>
<ui-button>Cancel</ui-button>

Slots and Composition

Slots let consumers pass content into your component, similar to React's children prop or Vue's slots.

tstypescript
class AlertBox extends HTMLElement {
  static observedAttributes = ['type'];
 
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    this.render();
  }
 
  attributeChangedCallback() {
    this.render();
  }
 
  private render() {
    const type = this.getAttribute('type') ?? 'info';
    const colors: Record<string, { bg: string; border: string }> = {
      info: { bg: '#eff6ff', border: '#3b82f6' },
      success: { bg: '#f0fdf4', border: '#22c55e' },
      warning: { bg: '#fffbeb', border: '#f59e0b' },
      error: { bg: '#fef2f2', border: '#ef4444' },
    };
    const color = colors[type] ?? colors.info;
 
    this.shadowRoot!.innerHTML = `
      <style>
        :host {
          display: block;
        }
        .alert {
          padding: 12px 16px;
          border-left: 4px solid ${color.border};
          background: ${color.bg};
          border-radius: 4px;
        }
        .title {
          font-weight: 600;
          margin-bottom: 4px;
        }
        ::slotted(*) {
          margin: 0;
        }
      </style>
      <div class="alert">
        <div class="title"><slot name="title"></slot></div>
        <div class="body"><slot></slot></div>
      </div>
    `;
  }
}
 
customElements.define('alert-box', AlertBox);
htmlhtml
<!-- Named and default slots -->
<alert-box type="warning">
  <span slot="title">Rate Limit Warning</span>
  <p>You have 5 API calls remaining in the current window.</p>
</alert-box>
 
<alert-box type="success">
  <span slot="title">Deployment Complete</span>
  <p>Version 2.3.1 is now live in production.</p>
</alert-box>

Custom Events

Web components communicate with the outside world through custom events. This follows the same pattern as native DOM events.

tstypescript
class SearchInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
 
  connectedCallback() {
    this.shadowRoot!.innerHTML = `
      <style>
        input {
          width: 100%;
          padding: 8px 12px;
          border: 1px solid #d1d5db;
          border-radius: 6px;
          font-size: 1rem;
          box-sizing: border-box;
        }
        input:focus {
          outline: none;
          border-color: #3b82f6;
          box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
        }
      </style>
      <input type="search" placeholder="${this.getAttribute('placeholder') ?? 'Search...'}" />
    `;
 
    const input = this.shadowRoot!.querySelector('input')!;
    let debounceTimer: ReturnType<typeof setTimeout>;
 
    input.addEventListener('input', () => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        // Dispatch custom event with search value
        this.dispatchEvent(
          new CustomEvent('search', {
            detail: { query: input.value },
            bubbles: true,      // Propagates up the DOM
            composed: true,     // Crosses shadow DOM boundary
          })
        );
      }, 300);
    });
  }
}
 
customElements.define('search-input', SearchInput);
htmlhtml
<search-input placeholder="Search users..."></search-input>
 
<script>
  document
    .querySelector('search-input')
    .addEventListener('search', (event) => {
      console.log('Search query:', event.detail.query);
      // Fetch results, filter list, etc.
    });
</script>

Using Web Components in Frameworks

Web components work in React, Vue, Angular, and Svelte. Framework integration is mostly seamless, with a few caveats around property passing versus attribute setting.

tstypescript
// React — use web components directly in JSX
function UserList({ users }: { users: User[] }) {
  return (
    <div>
      {users.map((user) => (
        <user-card
          key={user.id}
          name={user.name}
          email={user.email}
          avatar={user.avatar}
        />
      ))}
    </div>
  );
}
 
// React — listening to custom events requires a ref
function SearchPage() {
  const searchRef = useRef<HTMLElement>(null);
 
  useEffect(() => {
    const el = searchRef.current;
    if (!el) return;
 
    const handler = (e: Event) => {
      const query = (e as CustomEvent).detail.query;
      console.log('Search:', query);
    };
 
    el.addEventListener('search', handler);
    return () => el.removeEventListener('search', handler);
  }, []);
 
  return <search-input ref={searchRef} placeholder="Search..." />;
}
tstypescript
// ❌ Passing complex data as attributes (serialized to strings)
<user-card settings='{"theme":"dark","lang":"en"}'></user-card>
// Attributes are always strings — parsing JSON on every render is wasteful
 
// ✅ Passing complex data as properties
const card = document.querySelector('user-card') as UserCard;
card.settings = { theme: 'dark', lang: 'en' };
// Properties can be any JavaScript type — no serialization needed

Key Takeaways

  1. Web components are a browser standard — they work everywhere without a build step and will not break when frameworks change
  2. Shadow DOM provides true style encapsulation — styles inside a component cannot leak out, and external styles cannot leak in
  3. Use slots for composition — named and default slots let consumers pass content into your component, like props.children in React
  4. Dispatch custom events for communication — set bubbles: true and composed: true so events cross shadow DOM boundaries
  5. Prefer properties over attributes for complex data — attributes are always strings; properties accept any JavaScript type
  6. Best suited for design systems — when multiple frameworks need the same UI elements, web components are the shared foundation
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX