slice icon Context Slice

Purpose

Web components enable modular, reusable UI elements with encapsulated styles and behavior. Use these patterns when building apps that need multiple instances of the same element or when creating building blocks for other skills.

When to Use Web Components

Scenario Use Web Component?
Repeated UI element (cards, buttons, inputs) Yes
Complex widget with internal state Yes
Simple one-off element No—use regular HTML
Styling only No—use CSS classes

Basic Custom Element

<script>
class MyCounter extends HTMLElement {
  constructor() {
    super();
    this.count = 0;
  }

  connectedCallback() {
    this.render();
    this.querySelector('button').addEventListener('click', () => {
      this.count++;
      this.render();
    });
  }

  render() {
    this.innerHTML = `
      <div class="counter">
        <span>${this.count}</span>
        <button>+1</button>
      </div>
    `;
  }
}

customElements.define('my-counter', MyCounter);
</script>

<!-- Usage -->
<my-counter></my-counter>

With Shadow DOM (Style Encapsulation)

Use Shadow DOM when component styles should not leak out or be affected by page styles:

<script>
class StyledCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          background: #1a1a1a;
          border-radius: 12px;
          padding: 1.5rem;
        }

        h3 {
          margin: 0 0 0.5rem;
          color: #e5e5e5;
        }

        p {
          margin: 0;
          color: #888;
        }
      </style>
      <h3>${this.getAttribute('title') || 'Card'}</h3>
      <p><slot></slot></p>
    `;
  }
}

customElements.define('styled-card', StyledCard);
</script>

<!-- Usage -->
<styled-card title="Hello">This is the card content</styled-card>

Attributes and Properties

Pass data via attributes:

<script>
class UserBadge extends HTMLElement {
  static get observedAttributes() {
    return ['name', 'role'];
  }

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

  connectedCallback() {
    this.render();
  }

  render() {
    const name = this.getAttribute('name') || 'Anonymous';
    const role = this.getAttribute('role') || 'User';

    this.innerHTML = `
      <div class="badge">
        <strong>${name}</strong>
        <small>${role}</small>
      </div>
    `;
  }
}

customElements.define('user-badge', UserBadge);
</script>

<!-- Usage -->
<user-badge name="Alice" role="Admin"></user-badge>

Slots for Composition

Slots allow content injection:

<script>
class InfoBox extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const type = this.getAttribute('type') || 'info';
    const colors = {
      info: '#3b82f6',
      warning: '#f59e0b',
      error: '#ef4444',
      success: '#22c55e'
    };

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border-left: 4px solid ${colors[type]};
          background: ${colors[type]}15;
          padding: 1rem;
          border-radius: 0 8px 8px 0;
          margin: 1rem 0;
        }

        ::slotted(*) {
          margin: 0;
        }
      </style>
      <slot></slot>
    `;
  }
}

customElements.define('info-box', InfoBox);
</script>

<!-- Usage -->
<info-box type="warning">
  <p>This action cannot be undone.</p>
</info-box>

Named Slots

For complex layouts with multiple content areas:

<script>
class PageLayout extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: grid;
          grid-template-rows: auto 1fr auto;
          min-height: 100vh;
        }

        header, footer {
          padding: 1rem 2rem;
          background: #1a1a1a;
        }

        main {
          padding: 2rem;
        }
      </style>
      <header><slot name="header"></slot></header>
      <main><slot></slot></main>
      <footer><slot name="footer"></slot></footer>
    `;
  }
}

customElements.define('page-layout', PageLayout);
</script>

<!-- Usage -->
<page-layout>
  <h1 slot="header">My App</h1>
  <p>Main content goes here</p>
  <p slot="footer">© 2025</p>
</page-layout>

Event Communication

Components communicate via custom events:

<script>
class TodoItem extends HTMLElement {
  connectedCallback() {
    const text = this.getAttribute('text');
    const id = this.getAttribute('item-id');

    this.innerHTML = `
      <div class="todo-item">
        <span>${text}</span>
        <button class="delete">×</button>
      </div>
    `;

    this.querySelector('.delete').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('todo-delete', {
        bubbles: true,
        detail: { id }
      }));
    });
  }
}

customElements.define('todo-item', TodoItem);

// Parent listens for events
document.addEventListener('todo-delete', (e) => {
  console.log('Delete item:', e.detail.id);
});
</script>

Complete Example: Task List

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Task List</title>
  <style>
    *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

    :root {
      --bg: #0f0f0f;
      --surface: #1a1a1a;
      --text: #e5e5e5;
      --accent: #6366f1;
    }

    body {
      font-family: system-ui, sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      padding: 2rem;
    }
  </style>
</head>
<body>
  <task-list></task-list>

  <script>
    class TaskItem extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
      }

      connectedCallback() {
        const text = this.getAttribute('text');
        const done = this.hasAttribute('done');

        this.shadowRoot.innerHTML = `
          <style>
            :host {
              display: flex;
              align-items: center;
              gap: 0.75rem;
              padding: 0.75rem;
              background: #1a1a1a;
              border-radius: 8px;
              margin-bottom: 0.5rem;
            }

            input[type="checkbox"] {
              width: 1.25rem;
              height: 1.25rem;
              accent-color: #6366f1;
            }

            span {
              flex: 1;
              ${done ? 'text-decoration: line-through; opacity: 0.5;' : ''}
            }

            button {
              background: transparent;
              border: none;
              color: #888;
              cursor: pointer;
              font-size: 1.25rem;
            }

            button:hover { color: #ef4444; }
          </style>
          <input type="checkbox" ${done ? 'checked' : ''}>
          <span>${text}</span>
          <button>×</button>
        `;

        this.shadowRoot.querySelector('input').addEventListener('change', (e) => {
          this.dispatchEvent(new CustomEvent('task-toggle', { bubbles: true }));
        });

        this.shadowRoot.querySelector('button').addEventListener('click', () => {
          this.dispatchEvent(new CustomEvent('task-delete', { bubbles: true }));
        });
      }
    }

    class TaskList extends HTMLElement {
      constructor() {
        super();
        this.tasks = [];
      }

      connectedCallback() {
        this.render();

        this.addEventListener('task-toggle', (e) => {
          const item = e.target;
          const index = [...this.querySelectorAll('task-item')].indexOf(item);
          this.tasks[index].done = !this.tasks[index].done;
          this.render();
        });

        this.addEventListener('task-delete', (e) => {
          const item = e.target;
          const index = [...this.querySelectorAll('task-item')].indexOf(item);
          this.tasks.splice(index, 1);
          this.render();
        });
      }

      render() {
        this.innerHTML = `
          <div style="max-width: 400px; margin: 0 auto;">
            <h1 style="margin-bottom: 1rem;">Tasks</h1>
            <form style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
              <input type="text" placeholder="Add a task..."
                style="flex: 1; padding: 0.75rem; border-radius: 8px; border: 1px solid #333; background: #1a1a1a; color: #e5e5e5;">
              <button type="submit"
                style="padding: 0.75rem 1rem; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer;">
                Add
              </button>
            </form>
            <div class="tasks">
              ${this.tasks.map((t, i) =>
                `<task-item text="${t.text}" ${t.done ? 'done' : ''}></task-item>`
              ).join('')}
            </div>
          </div>
        `;

        this.querySelector('form').addEventListener('submit', (e) => {
          e.preventDefault();
          const input = this.querySelector('input');
          if (input.value.trim()) {
            this.tasks.push({ text: input.value.trim(), done: false });
            input.value = '';
            this.render();
          }
        });
      }
    }

    customElements.define('task-item', TaskItem);
    customElements.define('task-list', TaskList);
  </script>
</body>
</html>

Best Practices

  1. Naming: Use kebab-case with a prefix: my-, app-, or domain-specific like viz-chart
  2. Shadow DOM: Use for style isolation; skip if you need page styles to apply
  3. State: Keep state in the top-level component; pass down via attributes
  4. Events: Bubble up from children; listen at parent level
  5. Performance: Avoid re-rendering entire component; update only changed parts when possible