· Advanced

Web Components and Custom Elements

Build reusable components with native web standards.

Web Components let you create custom, reusable HTML elements that work everywhere—no framework required. Built on native browser APIs, they encapsulate functionality and styling, making them portable across projects and frameworks. In this guide, you’ll learn how to create custom elements, use shadow DOM, and leverage HTML templates to build truly reusable components.

Web Components are the future of modular web development—write once, use anywhere.

What Are Web Components?

Web Components combine three main technologies:

  1. Custom Elements - Define your own HTML tags
  2. Shadow DOM - Encapsulate styles and markup
  3. HTML Templates - Reusable HTML chunks

Together, they let you create components like:

<user-card name="Jane Smith" email="[email protected]"></user-card>
<countdown-timer end="2024-12-31"></countdown-timer>
<custom-dropdown options='["Option 1", "Option 2"]'></custom-dropdown>

Creating a Custom Element

Basic Example

// Define the component
class HelloWorld extends HTMLElement {
  constructor() {
    super();
    this.innerHTML = `<h1>Hello, World!</h1>`;
  }
}

// Register it
customElements.define('hello-world', HelloWorld);
<!-- Use it in HTML -->
<hello-world></hello-world>

With Attributes

class UserGreeting extends HTMLElement {
  constructor() {
    super();
  }
  
  connectedCallback() {
    const name = this.getAttribute('name') || 'Guest';
    this.innerHTML = `<p>Hello, ${name}!</p>`;
  }
}

customElements.define('user-greeting', UserGreeting);
<user-greeting name="Jane"></user-greeting>
<!-- Renders: Hello, Jane! -->

<user-greeting></user-greeting>
<!-- Renders: Hello, Guest! -->

Lifecycle Callbacks

Custom elements have lifecycle hooks:

class MyElement extends HTMLElement {
  constructor() {
    super();
    console.log('Element created');
  }
  
  connectedCallback() {
    console.log('Element added to page');
    this.render();
  }
  
  disconnectedCallback() {
    console.log('Element removed from page');
    // Cleanup code here
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
    this.render();
  }
  
  static get observedAttributes() {
    return ['name', 'age'];
  }
  
  render() {
    const name = this.getAttribute('name');
    const age = this.getAttribute('age');
    this.innerHTML = `<p>${name} is ${age} years old</p>`;
  }
}

customElements.define('my-element', MyElement);

Shadow DOM

Shadow DOM encapsulates styles and markup:

class StyledButton extends HTMLElement {
  constructor() {
    super();
    
    // Attach shadow root
    const shadow = this.attachShadow({ mode: 'open' });
    
    // Add styles (scoped to this component!)
    const style = document.createElement('style');
    style.textContent = `
      button {
        background: #0066cc;
        color: white;
        border: none;
        padding: 10px 20px;
        border-radius: 4px;
        cursor: pointer;
        font-size: 16px;
      }
      
      button:hover {
        background: #0052a3;
      }
    `;
    
    // Add button
    const button = document.createElement('button');
    button.textContent = this.getAttribute('label') || 'Click me';
    
    // Attach to shadow root
    shadow.appendChild(style);
    shadow.appendChild(button);
  }
}

customElements.define('styled-button', StyledButton);
<styled-button label="Submit"></styled-button>
<styled-button label="Cancel"></styled-button>
<!-- Each button has its own encapsulated styles -->

HTML Templates

Use <template> for reusable markup:

<template id="card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 1rem;
      margin: 1rem 0;
    }
    
    .card-title {
      font-size: 1.5rem;
      margin: 0 0 0.5rem 0;
    }
    
    .card-content {
      color: #666;
    }
  </style>
  
  <div class="card">
    <h2 class="card-title"></h2>
    <p class="card-content"></p>
  </div>
</template>

<script>
  class InfoCard extends HTMLElement {
    constructor() {
      super();
      
      const shadow = this.attachShadow({ mode: 'open' });
      
      // Clone template
      const template = document.getElementById('card-template');
      const clone = template.content.cloneNode(true);
      
      // Fill in content
      clone.querySelector('.card-title').textContent = this.getAttribute('title');
      clone.querySelector('.card-content').textContent = this.getAttribute('content');
      
      shadow.appendChild(clone);
    }
  }
  
  customElements.define('info-card', InfoCard);
</script>

<!-- Use the component -->
<info-card 
  title="Web Components" 
  content="Build reusable components with native APIs">
</info-card>

Slots for Content Projection

Slots let you pass content into components:

class AlertBox extends HTMLElement {
  constructor() {
    super();
    
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        .alert {
          padding: 1rem;
          border-radius: 4px;
          margin: 1rem 0;
        }
        
        .alert-error { background: #fee; border: 1px solid #fcc; }
        .alert-success { background: #efe; border: 1px solid #cfc; }
        .alert-warning { background: #ffc; border: 1px solid #fc6; }
        
        .alert-title {
          font-weight: bold;
          margin-bottom: 0.5rem;
        }
      </style>
      
      <div class="alert alert-${this.getAttribute('type') || 'error'}">
        <div class="alert-title">
          <slot name="title">Alert</slot>
        </div>
        <div class="alert-content">
          <slot></slot>
        </div>
      </div>
    `;
  }
}

customElements.define('alert-box', AlertBox);
<alert-box type="success">
  <span slot="title">Success!</span>
  Your form was submitted successfully.
</alert-box>

<alert-box type="error">
  <span slot="title">Error</span>
  Please fill out all required fields.
</alert-box>

Reactive Properties

Make components reactive to property changes:

class CounterButton extends HTMLElement {
  constructor() {
    super();
    this._count = 0;
    
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        button {
          padding: 10px 20px;
          font-size: 16px;
          cursor: pointer;
        }
      </style>
      <button>Count: <span class="count">0</span></button>
    `;
    
    this.button = shadow.querySelector('button');
    this.countSpan = shadow.querySelector('.count');
    
    this.button.addEventListener('click', () => {
      this.count++;
    });
  }
  
  get count() {
    return this._count;
  }
  
  set count(value) {
    this._count = value;
    this.countSpan.textContent = value;
    
    // Dispatch custom event
    this.dispatchEvent(new CustomEvent('countchanged', {
      detail: { count: value }
    }));
  }
}

customElements.define('counter-button', CounterButton);
<counter-button></counter-button>

<script>
  const counter = document.querySelector('counter-button');
  counter.addEventListener('countchanged', (e) => {
    console.log('Count changed to:', e.detail.count);
  });
</script>

Complete Example: User Card

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Web Components Demo</title>
</head>
<body>
  <h1>Team Members</h1>
  
  <user-card 
    name="Jane Smith"
    role="Senior Developer"
    email="[email protected]"
    avatar="https://i.pravatar.cc/150?img=1">
  </user-card>
  
  <user-card 
    name="John Doe"
    role="Designer"
    email="[email protected]"
    avatar="https://i.pravatar.cc/150?img=2">
  </user-card>
  
  <script>
    class UserCard extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
      }
      
      connectedCallback() {
        this.render();
      }
      
      attributeChangedCallback() {
        this.render();
      }
      
      static get observedAttributes() {
        return ['name', 'role', 'email', 'avatar'];
      }
      
      render() {
        const name = this.getAttribute('name') || 'Unknown';
        const role = this.getAttribute('role') || 'No role';
        const email = this.getAttribute('email') || '[email protected]';
        const avatar = this.getAttribute('avatar') || 'https://via.placeholder.com/150';
        
        this.shadowRoot.innerHTML = `
          <style>
            :host {
              display: block;
              font-family: system-ui, sans-serif;
            }
            
            .card {
              border: 1px solid #ddd;
              border-radius: 8px;
              padding: 1.5rem;
              margin: 1rem 0;
              display: flex;
              gap: 1rem;
              align-items: center;
              background: white;
              box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            }
            
            .avatar {
              width: 80px;
              height: 80px;
              border-radius: 50%;
              object-fit: cover;
            }
            
            .info {
              flex: 1;
            }
            
            .name {
              font-size: 1.25rem;
              font-weight: bold;
              margin: 0 0 0.25rem 0;
            }
            
            .role {
              color: #666;
              margin: 0 0 0.5rem 0;
            }
            
            .email {
              color: #0066cc;
              text-decoration: none;
            }
            
            .email:hover {
              text-decoration: underline;
            }
          </style>
          
          <div class="card">
            <img class="avatar" src="${avatar}" alt="${name}">
            <div class="info">
              <h2 class="name">${name}</h2>
              <p class="role">${role}</p>
              <a class="email" href="mailto:${email}">${email}</a>
            </div>
          </div>
        `;
      }
    }
    
    customElements.define('user-card', UserCard);
  </script>
</body>
</html>

Using Web Components with Frameworks

Web Components work with any framework:

// React
function App() {
  return <user-card name="Jane Smith" role="Developer"></user-card>;
}

// Vue
<template>
  <user-card name="Jane Smith" role="Developer"></user-card>
</template>

// Angular
<user-card name="Jane Smith" role="Developer"></user-card>

// Plain HTML
<user-card name="Jane Smith" role="Developer"></user-card>

Best Practices

Use semantic names - user-card not ucAlways include hyphens - Required for custom elements ✅ Encapsulate styles - Use Shadow DOM ✅ Handle attributes - Use observedAttributes and lifecycle callbacks ✅ Emit custom events - For component communication ✅ Provide fallback content - For browsers without support ✅ Document your API - List available attributes and events ✅ Test across browsers - Ensure compatibility

Browser Support

Web Components are supported in all modern browsers:

  • Chrome/Edge (full support)
  • Firefox (full support)
  • Safari (full support)
  • For older browsers, use polyfills

Common Pitfalls

Forgetting the hyphen - Custom element names must include a hyphen ❌ Not using Shadow DOM - Styles leak without encapsulation ❌ Blocking the main thread - Keep constructors lightweight ❌ Not handling attribute changes - Implement attributeChangedCallbackOverusing - Not everything needs to be a component

Keep Learning

Try building Web Components in the htmlEditor.net playground today!

Web Components represent the future of reusable web development. With native browser support and framework-agnostic compatibility, they’re a powerful tool for building modular, maintainable applications.

← Back to all blog posts

    Share: