Docs

19.8-Web-Components

19.8 Web Components

Overview

Web Components are a set of web platform APIs that allow you to create reusable, encapsulated custom HTML elements. They work across modern browsers without frameworks.

Learning Objectives

  • Create Custom Elements with ES6 classes
  • Use Shadow DOM for style encapsulation
  • Work with HTML Templates and Slots
  • Understand the Custom Element lifecycle
  • Build reusable component libraries

Custom Elements

Basic Custom Element

class MyElement extends HTMLElement {
  constructor() {
    super();
    this.textContent = 'Hello from Custom Element!';
  }
}

// Register the element
customElements.define('my-element', MyElement);
<my-element></my-element>

Lifecycle Callbacks

class LifecycleElement extends HTMLElement {
  constructor() {
    super();
    console.log('Constructor called');
  }

  // Called when element is added to DOM
  connectedCallback() {
    console.log('Element added to page');
  }

  // Called when element is removed from DOM
  disconnectedCallback() {
    console.log('Element removed from page');
  }

  // Called when element is moved to new document
  adoptedCallback() {
    console.log('Element moved to new document');
  }

  // Called when observed attributes change
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
  }

  // Specify which attributes to observe
  static get observedAttributes() {
    return ['title', 'count'];
  }
}

customElements.define('lifecycle-element', LifecycleElement);

Shadow DOM

Encapsulated Styling

class ShadowElement extends HTMLElement {
  constructor() {
    super();

    // Create shadow root
    const shadow = this.attachShadow({ mode: 'open' });

    // Add content
    shadow.innerHTML = `
            <style>
                /* Styles are scoped to this component */
                :host {
                    display: block;
                    padding: 16px;
                    border: 1px solid #ccc;
                }
                
                :host([theme="dark"]) {
                    background: #333;
                    color: white;
                }
                
                h2 {
                    color: blue;
                    margin: 0;
                }
            </style>
            <h2>Shadow DOM Content</h2>
            <p>This is encapsulated</p>
        `;
  }
}

customElements.define('shadow-element', ShadowElement);

Shadow DOM Modes

// Open - shadow root accessible via element.shadowRoot
this.attachShadow({ mode: 'open' });

// Closed - shadow root not accessible
const shadow = this.attachShadow({ mode: 'closed' });

HTML Templates

Using Templates

<template id="my-template">
  <style>
    .container {
      padding: 20px;
      background: #f0f0f0;
    }
  </style>
  <div class="container">
    <h2><slot name="title">Default Title</slot></h2>
    <p><slot>Default content</slot></p>
  </div>
</template>
class TemplateElement extends HTMLElement {
  constructor() {
    super();

    const shadow = this.attachShadow({ mode: 'open' });
    const template = document.getElementById('my-template');
    const content = template.content.cloneNode(true);

    shadow.appendChild(content);
  }
}

customElements.define('template-element', TemplateElement);

Using Slots

<template-element>
  <span slot="title">Custom Title</span>
  <p>This goes in the default slot</p>
</template-element>

Reactive Properties

class ReactiveElement extends HTMLElement {
  static get observedAttributes() {
    return ['count'];
  }

  constructor() {
    super();
    this._count = 0;
    this.attachShadow({ mode: 'open' });
    this.render();
  }

  get count() {
    return this._count;
  }

  set count(value) {
    this._count = parseInt(value) || 0;
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'count') {
      this.count = newValue;
    }
  }

  connectedCallback() {
    this.shadowRoot
      .querySelector('button')
      .addEventListener('click', () => this.increment());
  }

  increment() {
    this.count++;
    this.setAttribute('count', this.count);
    this.dispatchEvent(
      new CustomEvent('count-changed', {
        detail: { count: this.count },
      })
    );
  }

  render() {
    this.shadowRoot.innerHTML = `
            <style>
                button { padding: 10px 20px; }
                span { margin-left: 10px; font-size: 1.5em; }
            </style>
            <button>Increment</button>
            <span>${this.count}</span>
        `;
  }
}

customElements.define('reactive-element', ReactiveElement);

Custom Events

class EventElement extends HTMLElement {
  connectedCallback() {
    this.addEventListener('click', () => {
      // Dispatch custom event
      this.dispatchEvent(
        new CustomEvent('my-event', {
          bubbles: true, // Event bubbles up
          composed: true, // Crosses shadow DOM boundary
          detail: {
            // Custom data
            message: 'Hello from custom event',
          },
        })
      );
    });
  }
}

customElements.define('event-element', EventElement);

// Listen for custom event
document.querySelector('event-element').addEventListener('my-event', (e) => {
  console.log(e.detail.message);
});

Extending Built-in Elements

class FancyButton extends HTMLButtonElement {
  constructor() {
    super();
    this.style.background = 'linear-gradient(45deg, #ff6b6b, #feca57)';
    this.style.border = 'none';
    this.style.padding = '10px 20px';
    this.style.color = 'white';
    this.style.borderRadius = '5px';
    this.style.cursor = 'pointer';
  }

  connectedCallback() {
    this.addEventListener('mouseenter', () => {
      this.style.transform = 'scale(1.05)';
    });
    this.addEventListener('mouseleave', () => {
      this.style.transform = 'scale(1)';
    });
  }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });
<button is="fancy-button">Click Me</button>

Form-Associated Custom Elements

class CustomInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this._internals = this.attachInternals();
    this._value = '';

    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
            <input type="text" />
        `;

    shadow.querySelector('input').addEventListener('input', (e) => {
      this._value = e.target.value;
      this._internals.setFormValue(this._value);
    });
  }

  get value() {
    return this._value;
  }

  set value(v) {
    this._value = v;
    this.shadowRoot.querySelector('input').value = v;
    this._internals.setFormValue(v);
  }

  // Form callbacks
  formResetCallback() {
    this.value = '';
  }

  formStateRestoreCallback(state) {
    this.value = state;
  }
}

customElements.define('custom-input', CustomInput);

Best Practices

  1. Use meaningful names - Custom element names must contain a hyphen
  2. Leverage Shadow DOM - For style encapsulation
  3. Define observed attributes - Only observe what you need
  4. Cleanup in disconnectedCallback - Remove event listeners, observers
  5. Use slots for composition - Allow flexible content projection
  6. Dispatch meaningful events - For component communication

Browser Support

// Check for Web Components support
const supportsWebComponents =
  'customElements' in window &&
  'attachShadow' in Element.prototype &&
  'content' in document.createElement('template');

if (!supportsWebComponents) {
  // Load polyfills
}

Summary

FeaturePurpose
Custom ElementsDefine new HTML elements
Shadow DOMEncapsulate styles and markup
TemplatesReusable markup fragments
SlotsContent projection
Lifecycle CallbacksReact to element lifecycle

Resources

.8 Web Components - JavaScript Tutorial | DeepML