Skip to main content
โšก Calmops

Web Components: Building Reusable Custom Elements

Web Components are a suite of different technologies allowing you to create reusable custom elements with their functionality encapsulated away from the rest of your code. This comprehensive guide covers everything you need to know.

The Three Pillars

Web Components consist of three main technologies:

  1. Custom Elements - Define new HTML tags
  2. Shadow DOM - Encapsulated style and markup
  3. HTML Templates - Reusable markup templates
flowchart TD
    subgraph WebComponents["Web Components"]
        CE["Custom Elements<br/>defineElement()"]
        SD["Shadow DOM<br/>attachShadow()"]
        HT["HTML Templates<br/><template>"]
    end
    
    CE --> Combined["Reusable Components"]
    SD --> Combined
    HT --> Combined

Custom Elements

Defining a Custom Element

class MyElement extends HTMLElement {
  constructor() {
    super();
    // Initialize component
  }
  
  connectedCallback() {
    // Element added to DOM
    console.log('MyElement added to page');
  }
  
  disconnectedCallback() {
    // Element removed from DOM
    console.log('MyElement removed from page');
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    // Attribute changed
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
  }
  
  static get observedAttributes() {
    return ['data', 'variant', 'disabled'];
  }
}

// Register the custom element
customElements.define('my-element', MyElement);
<!-- Use the custom element -->
<my-element data="value" variant="primary"></my-element>

Autonomous vs Customized Built-in Elements

// Autonomous custom element (entirely new)
class FancyButton extends HTMLElement {
  constructor() {
    super();
  }
}
customElements.define('fancy-button', FancyButton);

// Customized built-in element (extends existing)
class FancyDiv extends HTMLDivElement {
  constructor() {
    super();
  }
}
customElements.define('fancy-div', FancyDiv, { extends: 'div' });
<!-- Autonomous -->
<fancy-button>Click me</fancy-button>

<!-- Customized built-in -->
<div is="fancy-div">I'm fancy</div>

Shadow DOM

Creating Shadow DOM

class MyComponent extends HTMLElement {
  constructor() {
    super();
    
    // Attach shadow DOM - 'open' allows access from JS
    this.attachShadow({ mode: 'open' });
    
    // 'closed' hides shadow root
    // this.attachShadow({ mode: 'closed' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 1rem;
        }
        
        :host([hidden]) {
          display: none;
        }
        
        .content {
          color: #333;
        }
      </style>
      <div class="content">
        <slot></slot>
      </div>
    `;
  }
}

:host Selectors

/* The host element itself */
:host {
  display: block;
}

/* Based on host's attributes */
:host([theme="dark"]) {
  background: #1a1a1a;
  color: white;
}

:host([disabled]) {
  opacity: 0.5;
  pointer-events: none;
}

/* Context-based styling */
:host-context(.dark-mode) {
  background: #333;
}

Slots and Content Distribution

<!-- Component template -->
<template id="my-card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 1rem;
    }
    
    .header {
      font-weight: bold;
      margin-bottom: 0.5rem;
    }
  </style>
  
  <div class="card">
    <div class="header">
      <slot name="header">Default Title</slot>
    </div>
    <div class="body">
      <slot></slot>
    </div>
  </div>
</template>
<!-- Using the component -->
<my-card>
  <span slot="header">Custom Title</span>
  <p>This is the body content.</p>
</my-card>
// Named slots in JavaScript
class MyComponent extends HTMLElement {
  connectedCallback() {
    const slot = this.shadowRoot.querySelector('slot[name="header"]');
    
    slot.addEventListener('slotchange', (e) => {
      const assignedElements = slot.assignedElements();
      console.log('Header content:', assignedElements);
    });
  }
}

HTML Templates

Using Templates

<template id="my-template">
  <style>
    p {
      color: blue;
    }
  </style>
  <p>Template content</p>
</template>

<script>
  const template = document.getElementById('my-template');
  
  class MyElement extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
      // Clone the template
      const clone = template.content.cloneNode(true);
      this.shadowRoot.appendChild(clone);
    }
  }
  
  customElements.define('my-element', MyElement);
</script>

Template with Parameters

class UserCard extends HTMLElement {
  static get observedAttributes() {
    return ['name', 'email', 'avatar'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
  }
  
  attributeChangedCallback() {
    this.render();
  }
  
  render() {
    const name = this.getAttribute('name') || 'Unknown';
    const email = this.getAttribute('email') || '';
    const avatar = this.getAttribute('avatar') || 'default.png';
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        
        .card {
          display: flex;
          align-items: center;
          gap: 1rem;
          padding: 1rem;
          border: 1px solid #ddd;
          border-radius: 8px;
        }
        
        img {
          width: 48px;
          height: 48px;
          border-radius: 50%;
        }
        
        .info h3 {
          margin: 0;
          font-size: 1rem;
        }
        
        .info p {
          margin: 0;
          color: #666;
          font-size: 0.875rem;
        }
      </style>
      
      <div class="card">
        <img src="${avatar}" alt="${name}">
        <div class="info">
          <h3>${name}</h3>
          <p>${email}</p>
        </div>
      </div>
    `;
  }
}

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

Lifecycle Callbacks

Complete Lifecycle

class LifecycleElement extends HTMLElement {
  constructor() {
    super();
    console.log('1. constructor() - Element created');
  }
  
  static get observedAttributes() {
    return ['data', 'count'];
  }
  
  connectedCallback() {
    console.log('2. connectedCallback() - Added to DOM');
    // Initial setup
  }
  
  disconnectedCallback() {
    console.log('3. disconnectedCallback() - Removed from DOM');
    // Cleanup
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`4. attributeChangedCallback() - ${name}: ${oldValue} โ†’ ${newValue}`);
  }
  
  adoptedCallback() {
    console.log('5. adoptedCallback() - Moved to new document');
  }
}

Cleanup in disconnectedCallback

class EventElement extends HTMLElement {
  connectedCallback() {
    // Add event listeners
    this.handleClick = this.handleClick.bind(this);
    this.addEventListener('click', this.handleClick);
    
    // Start timers
    this.timer = setInterval(() => this.tick(), 1000);
  }
  
  disconnectedCallback() {
    // Clean up!
    this.removeEventListener('click', this.handleClick);
    clearInterval(this.timer);
  }
  
  handleClick(e) {
    console.log('Clicked!');
  }
  
  tick() {
    // Do something
  }
}

Properties and Attributes

Reflecting Properties

class ReflectElement extends HTMLElement {
  // Define getter/setter
  get active() {
    return this.hasAttribute('active');
  }
  
  set active(value) {
    // Reflect to attribute
    if (value) {
      this.setAttribute('active', '');
    } else {
      this.removeAttribute('active');
    }
  }
  
  // Or use static property
  static get observedAttributes() {
    return ['active', 'value'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    // Sync attribute โ†’ property
    if (name === 'active') {
      this._active = newValue !== null;
    }
  }
}

Complex Properties

class DataElement extends HTMLElement {
  get items() {
    return this._items || [];
  }
  
  set items(value) {
    this._items = value;
    this.render();
  }
  
  // JSON attribute
  get config() {
    try {
      return JSON.parse(this.getAttribute('config'));
    } catch {
      return {};
    }
  }
  
  set config(value) {
    this.setAttribute('config', JSON.stringify(value));
  }
}

Events and Custom Events

Dispatching Events

class ButtonElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <button type="button"><slot></slot></button>
    `;
    
    this.button = this.shadowRoot.querySelector('button');
    this.button.addEventListener('click', this.handleClick.bind(this));
  }
  
  handleClick(e) {
    // Dispatch custom event
    this.dispatchEvent(new CustomEvent('button-click', {
      bubbles: true,
      composed: true,
      detail: {
        timestamp: Date.now()
      }
    }));
  }
}
// Listen for custom event
document.querySelector('my-button').addEventListener('button-click', (e) => {
  console.log('Button clicked at:', e.detail.timestamp);
});

Changing Events

class FormInput extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="text">
    `;
    
    this.input = this.shadowRoot.querySelector('input');
    this.input.addEventListener('input', this.handleInput.bind(this));
    this.input.addEventListener('change', this.handleChange.bind(this));
  }
  
  handleInput(e) {
    // Dispatch input event that bubbles through shadow boundary
    this.dispatchEvent(new Event('input', {
      bubbles: true,
      composed: true
    }));
  }
  
  handleChange(e) {
    this.dispatchEvent(new Event('change', {
      bubbles: true,
      composed: true
    }));
  }
}

Styling Best Practices

Encapsulated Styles

class StyledComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
        }
        
        /* Component styles */
        .container {
          padding: 1rem;
          background: white;
          border-radius: 8px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        
        /* Use CSS variables for theming */
        :host([theme="dark"]) .container {
          background: #1a1a1a;
          color: white;
        }
        
        /* Focus styles */
        :host(:focus-within) {
          outline: 2px solid blue;
        }
      </style>
      
      <div class="container">
        <slot></slot>
      </div>
    `;
  }
}

Inheriting Styles

class TextComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    
    this.shadowRoot.innerHTML = `
      <style>
        /* Inherit font from document */
        :host {
          font-family: inherit;
          font-size: inherit;
          color: inherit;
        }
      </style>
      <p><slot></slot></p>
    </div>
    `;
  }
}

Forms and Web Components

Making Components Work with Forms

class FancyInput extends HTMLElement {
  static formAssociated = true;
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.internals = this.attachInternals();
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <input type="text">
    `;
    
    this.input = this.shadowRoot.querySelector('input');
    this.input.addEventListener('input', () => {
      // Update value for form
      this.internals.setFormValue(this.input.value);
    });
    
    this.input.addEventListener('invalid', () => {
      this.internals.setValidity(
        { customError: true },
        'Please enter a value'
      );
    });
  }
  
  // Form lifecycle
  formDisabledCallback(disabled) {
    this.input.disabled = disabled;
  }
  
  formResetCallback() {
    this.input.value = '';
    this.internals.setValidity({});
  }
  
  formStateRestoreCallback(state, mode) {
    this.input.value = state;
  }
}
<!-- Works with native forms -->
<form>
  <fancy-input name="username" required></fancy-input>
  <button type="submit">Submit</button>
</form>

Use Cases

class ModalDialog extends HTMLElement {
  static get observedAttributes() {
    return ['open', 'title'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
  }
  
  attributeChangedCallback() {
    this.render();
  }
  
  render() {
    const open = this.hasAttribute('open');
    const title = this.getAttribute('title') || 'Modal';
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: ${open ? 'flex' : 'none'};
        }
        
        .overlay {
          position: fixed;
          inset: 0;
          background: rgba(0,0,0,0.5);
          display: flex;
          align-items: center;
          justify-content: center;
        }
        
        .modal {
          background: white;
          border-radius: 8px;
          padding: 1.5rem;
          min-width: 300px;
          max-width: 90vw;
        }
        
        header {
          display: flex;
          justify-content: space-between;
          margin-bottom: 1rem;
        }
        
        button.close {
          background: none;
          border: none;
          font-size: 1.5rem;
          cursor: pointer;
        }
      </style>
      
      <div class="overlay" part="overlay">
        <div class="modal" part="modal">
          <header>
            <h2>${title}</h2>
            <button class="close" onclick="this.closest('modal-dialog').close()">ร—</button>
          </header>
          <div class="content">
            <slot></slot>
          </div>
        </div>
      </div>
    `;
  }
  
  close() {
    this.removeAttribute('open');
  }
  
  show() {
    this.setAttribute('open', '');
  }
}

customElements.define('modal-dialog', ModalDialog);

Tabs Component

class TabGroup extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._selectedTab = null;
  }
  
  connectedCallback() {
    this.render();
    
    // Setup tab switching
    this.shadowRoot.querySelectorAll('[role="tab"]').forEach(tab => {
      tab.addEventListener('click', () => this.selectTab(tab));
    });
  }
  
  selectTab(tab) {
    const tabName = tab.dataset.tab;
    
    // Update tab states
    this.shadowRoot.querySelectorAll('[role="tab"]').forEach(t => {
      t.setAttribute('aria-selected', t === tab);
    });
    
    // Update panel visibility
    this.shadowRoot.querySelectorAll('[role="tabpanel"]').forEach(panel => {
      if (panel.dataset.tab === tabName) {
        panel.hidden = false;
      } else {
        panel.hidden = true;
      }
    });
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
        }
        
        [role="tablist"] {
          display: flex;
          border-bottom: 1px solid #ddd;
        }
        
        [role="tab"] {
          padding: 0.75rem 1rem;
          background: none;
          border: none;
          cursor: pointer;
          border-bottom: 2px solid transparent;
        }
        
        [role="tab"][aria-selected="true"] {
          border-bottom-color: blue;
        }
        
        [role="tabpanel"] {
          padding: 1rem;
        }
        
        [role="tabpanel"][hidden] {
          display: none;
        }
      </style>
      
      <div role="tablist">
        <slot name="tab"></slot>
      </div>
      <slot name="panel"></slot>
    `;
  }
}

customElements.define('tab-group', TabGroup);

External Resources

Conclusion

Web Components provide a native way to create reusable, encapsulated HTML elements. Key points:

  • Use customElements.define() to register new elements
  • Use Shadow DOM for style encapsulation
  • Use <template> for reusable markup
  • Implement lifecycle callbacks: connectedCallback, disconnectedCallback, etc.
  • Reflect properties to attributes for reactivity
  • Dispatch custom events for communication

Web Components work with any framework (React, Vue, Angular) and can be used in plain HTML pages. They provide true framework-agnostic reusability.

Comments