Steven's Knowledge
Advanced Features

Web Components

Custom Elements, Shadow DOM, HTML Templates, and Slots — building framework-agnostic, encapsulated UI components

Web Components

Web Components is a suite of browser-native technologies for creating reusable, encapsulated custom HTML elements that work across any framework or no framework at all.

Problems Solved

ProblemHow Web Components Solve It
CSS leaks across componentsShadow DOM provides style encapsulation
Framework lock-in for shared UICustom Elements work everywhere
No native component model in HTMLCustom Elements extend HTML vocabulary
Markup duplicationHTML Templates enable declarative reuse
Tight coupling of component internalsShadow DOM hides implementation details

Architecture Overview

Web Components
├── Custom Elements        Define new HTML tags with lifecycle
├── Shadow DOM             Encapsulated DOM subtree and styles
├── HTML Templates         Inert, reusable markup fragments
└── Slots                  Content projection from light DOM

Custom Elements

Defining a Custom Element

class UserCard extends HTMLElement {
  // Called when element is added to the DOM
  connectedCallback() {
    const name = this.getAttribute('name') || 'Unknown';
    const role = this.getAttribute('role') || '';
    this.innerHTML = `
      <div class="card">
        <h3>${name}</h3>
        <p>${role}</p>
      </div>
    `;
  }

  // Observed attributes for attributeChangedCallback
  static get observedAttributes() {
    return ['name', 'role'];
  }

  // Called when observed attributes change
  attributeChangedCallback(attr, oldVal, newVal) {
    if (oldVal !== newVal) {
      this.connectedCallback(); // Re-render
    }
  }

  // Called when element is removed from the DOM
  disconnectedCallback() {
    // Cleanup: remove event listeners, cancel timers, etc.
  }
}

// Register the element — name must contain a hyphen
customElements.define('user-card', UserCard);
<!-- Usage in HTML -->
<user-card name="Alice" role="Engineer"></user-card>

Lifecycle Callbacks

CallbackWhen It FiresCommon Use
constructor()Element createdInitialize state, attach Shadow DOM
connectedCallback()Added to DOMRender, attach listeners, fetch data
disconnectedCallback()Removed from DOMCleanup listeners, cancel requests
attributeChangedCallback()Attribute modifiedUpdate rendering
adoptedCallback()Moved to new documentRe-initialize context

Extending Built-in Elements

class FancyButton extends HTMLButtonElement {
  connectedCallback() {
    this.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
    this.style.color = 'white';
    this.style.border = 'none';
    this.style.padding = '8px 16px';
    this.style.borderRadius = '4px';
    this.style.cursor = 'pointer';
  }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });
<!-- Must use "is" attribute for customized built-ins -->
<button is="fancy-button">Click Me</button>

Shadow DOM

Shadow DOM creates an encapsulated DOM subtree where styles and markup are isolated from the rest of the page.

Attaching Shadow DOM

class StyledCard extends HTMLElement {
  constructor() {
    super();
    // mode: 'open' allows external JS access via element.shadowRoot
    // mode: 'closed' hides the shadow root entirely
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        /* These styles are scoped — won't leak out */
        :host {
          display: block;
          border: 1px solid #e2e8f0;
          border-radius: 8px;
          padding: 16px;
        }
        :host([variant="highlighted"]) {
          border-color: #667eea;
          background: #f7f8ff;
        }
        h3 { margin: 0 0 8px; color: #1a202c; }
        p { margin: 0; color: #718096; }

        /* ::slotted() styles projected content */
        ::slotted(a) { color: #667eea; }
      </style>
      <h3><slot name="title">Default Title</slot></h3>
      <p><slot>Default content</slot></p>
    `;
  }
}

customElements.define('styled-card', StyledCard);
<styled-card variant="highlighted">
  <span slot="title">Custom Title</span>
  This is the projected content.
</styled-card>

Shadow DOM Boundaries

Document DOM (Light DOM)
├── <styled-card>
│   ├── #shadow-root (open)          ← Shadow boundary
│   │   ├── <style>...</style>       ← Scoped styles
│   │   ├── <h3><slot name="title"> ← Named slot
│   │   └── <p><slot>               ← Default slot
│   ├── <span slot="title">         ← Light DOM, projected into named slot
│   └── "This is the content"       ← Light DOM, projected into default slot

Style Encapsulation Rules

SelectorWhere It WorksPurpose
:hostInside shadowStyle the host element itself
:host([attr])Inside shadowStyle host based on attributes
:host-context(.class)Inside shadowStyle based on ancestor context
::slotted(selector)Inside shadowStyle projected light DOM content
::part(name)Outside shadowStyle exported shadow DOM parts
CSS custom propertiesCross boundaryTheming via --var inheritance

Theming with CSS Custom Properties

CSS custom properties cross the shadow boundary, making them ideal for theming:

class ThemeButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        button {
          background: var(--btn-bg, #3182ce);
          color: var(--btn-color, white);
          padding: var(--btn-padding, 8px 16px);
          border: none;
          border-radius: var(--btn-radius, 4px);
          font-size: var(--btn-font-size, 14px);
          cursor: pointer;
        }
        button:hover {
          opacity: 0.9;
        }
      </style>
      <button><slot>Button</slot></button>
    `;
  }
}

customElements.define('theme-button', ThemeButton);
/* Theme from outside */
theme-button {
  --btn-bg: #e53e3e;
  --btn-radius: 20px;
  --btn-padding: 12px 24px;
}

Exposing Parts with ::part()

class FancyInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        .wrapper { display: flex; flex-direction: column; gap: 4px; }
        label { font-size: 12px; font-weight: 600; }
        input { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
      </style>
      <div class="wrapper">
        <label part="label"><slot name="label">Label</slot></label>
        <input part="input" />
      </div>
    `;
  }
}

customElements.define('fancy-input', FancyInput);
/* External styles targeting exported parts */
fancy-input::part(label) { color: #2d3748; }
fancy-input::part(input) { border-color: #667eea; }
fancy-input::part(input):focus { outline: 2px solid #667eea; }

HTML Templates

<template> elements hold inert markup that isn't rendered until cloned and inserted.

<template id="card-template">
  <div class="card">
    <h3 class="card-title"></h3>
    <p class="card-body"></p>
    <button class="card-action">Action</button>
  </div>
</template>

<script>
function createCard(title, body) {
  const template = document.getElementById('card-template');
  const clone = template.content.cloneNode(true);

  clone.querySelector('.card-title').textContent = title;
  clone.querySelector('.card-body').textContent = body;

  return clone;
}

// Stamp out multiple cards from the same template
document.body.append(
  createCard('Card 1', 'First card content'),
  createCard('Card 2', 'Second card content')
);
</script>

Slots

Slots allow consumers to inject content into predefined locations inside a shadow DOM.

Named and Default Slots

class AppLayout extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: grid; grid-template-rows: auto 1fr auto; min-height: 100vh; }
        header { background: #1a202c; color: white; padding: 16px; }
        main { padding: 24px; }
        footer { background: #f7fafc; padding: 16px; text-align: center; }
      </style>
      <header><slot name="header">Default Header</slot></header>
      <main><slot>Main Content</slot></main>
      <footer><slot name="footer">Default Footer</slot></footer>
    `;
  }
}

customElements.define('app-layout', AppLayout);
<app-layout>
  <nav slot="header">My App Navigation</nav>
  <article>This goes into the default slot (main area)</article>
  <p slot="footer">&copy; 2025 My App</p>
</app-layout>

Slot Change Events

class DynamicList extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <div class="count">Items: 0</div>
      <slot></slot>
    `;

    const slot = shadow.querySelector('slot');
    slot.addEventListener('slotchange', () => {
      const items = slot.assignedElements();
      shadow.querySelector('.count').textContent = `Items: ${items.length}`;
    });
  }
}

customElements.define('dynamic-list', DynamicList);

Declarative Shadow DOM

Server-rendered shadow DOM without JavaScript:

<styled-card>
  <template shadowrootmode="open">
    <style>
      :host { display: block; padding: 16px; border: 1px solid #ccc; }
    </style>
    <slot></slot>
  </template>
  <p>This content is visible immediately, no JS needed.</p>
</styled-card>

Real-World Use Cases

Design System Components

// Framework-agnostic design system
class DsButton extends HTMLElement {
  static get observedAttributes() {
    return ['variant', 'size', 'disabled'];
  }

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

  connectedCallback() { this.render(); }
  attributeChangedCallback() { this.render(); }

  render() {
    const variant = this.getAttribute('variant') || 'primary';
    const size = this.getAttribute('size') || 'md';

    const styles = {
      primary: 'background: #3182ce; color: white;',
      secondary: 'background: #edf2f7; color: #1a202c;',
      danger: 'background: #e53e3e; color: white;',
    };

    const sizes = {
      sm: 'padding: 4px 8px; font-size: 12px;',
      md: 'padding: 8px 16px; font-size: 14px;',
      lg: 'padding: 12px 24px; font-size: 16px;',
    };

    this.shadowRoot.innerHTML = `
      <style>
        button {
          ${styles[variant] || styles.primary}
          ${sizes[size] || sizes.md}
          border: none; border-radius: 4px; cursor: pointer;
          font-family: inherit;
        }
        button:hover { opacity: 0.9; }
        button:disabled { opacity: 0.5; cursor: not-allowed; }
      </style>
      <button ${this.hasAttribute('disabled') ? 'disabled' : ''}>
        <slot></slot>
      </button>
    `;
  }
}

customElements.define('ds-button', DsButton);
<!-- Works in React, Vue, Angular, Svelte, or plain HTML -->
<ds-button variant="primary" size="lg">Submit</ds-button>
<ds-button variant="danger">Delete</ds-button>

Micro-Frontend Isolation

// Each micro-frontend is encapsulated in a shadow DOM
class MicroApp extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  async connectedCallback() {
    const src = this.getAttribute('src');
    if (!src) return;

    try {
      const res = await fetch(src);
      const html = await res.text();
      this.shadowRoot.innerHTML = html;

      // Execute scripts within shadow DOM context
      this.shadowRoot.querySelectorAll('script').forEach(script => {
        const newScript = document.createElement('script');
        newScript.textContent = script.textContent;
        this.shadowRoot.appendChild(newScript);
      });
    } catch (e) {
      this.shadowRoot.innerHTML = `<p>Failed to load micro-app</p>`;
    }
  }

  disconnectedCallback() {
    this.shadowRoot.innerHTML = '';
  }
}

customElements.define('micro-app', MicroApp);

Form Integration

// Custom form element with FormAssociated API
class StarRating extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: 'open' });
    this._value = 0;
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.addEventListener('click', (e) => {
      const star = e.target.closest('[data-value]');
      if (star) {
        this._value = parseInt(star.dataset.value);
        this.internals.setFormValue(this._value);
        this.render();
      }
    });
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-flex; gap: 4px; cursor: pointer; }
        .star { font-size: 24px; color: #cbd5e0; transition: color 0.15s; }
        .star.active { color: #ecc94b; }
      </style>
      ${[1,2,3,4,5].map(i => `
        <span class="star ${i <= this._value ? 'active' : ''}" data-value="${i}">&#9733;</span>
      `).join('')}
    `;
  }

  // Standard form APIs
  get value() { return this._value; }
  get form() { return this.internals.form; }
  get name() { return this.getAttribute('name'); }
  get validity() { return this.internals.validity; }
}

customElements.define('star-rating', StarRating);
<form>
  <star-rating name="rating"></star-rating>
  <button type="submit">Submit</button>
</form>

When to Use Web Components

ScenarioRecommendation
Shared design system across frameworksUse Web Components
Single-framework app, internal componentsUse framework components
Third-party widget distributionUse Web Components
Micro-frontend isolationUse Shadow DOM
SSR-heavy applicationUse Declarative Shadow DOM or framework
Rich interactivity within a frameworkUse framework components

Best Practices

  • Use mode: 'open' for Shadow DOM unless you have a specific reason to hide internals
  • Prefer CSS custom properties over ::part() for theming — they compose better
  • Always call super() first in the constructor
  • Clean up event listeners and observers in disconnectedCallback()
  • Use observedAttributes + attributeChangedCallback for reactive attribute changes
  • Name custom elements with a namespace prefix (e.g., ds-button, app-modal) to avoid collisions

On this page