Lab 02: Web Components

Time: 30 minutes | Level: Advanced | Docker: docker run -it --rm node:20-alpine sh

Overview

Build native Web Components using Custom Elements v1, Shadow DOM, <template>/<slot>, and CSS custom properties crossing the Shadow DOM boundary.


Step 1: Custom Elements Lifecycle

class MyButton extends HTMLElement {
  // Declare which attributes trigger attributeChangedCallback
  static get observedAttributes() {
    return ['variant', 'disabled', 'size', 'label'];
  }

  constructor() {
    super();
    // ✓ Can: attach shadow DOM, set up initial state
    // ✗ Can't: access children, inspect DOM, set attributes
    this._shadow = this.attachShadow({ mode: 'open' });
  }

  // Element inserted into DOM
  connectedCallback() {
    this.render();
    this._addEventListeners();
  }

  // Element removed from DOM
  disconnectedCallback() {
    this._removeEventListeners();
    // Cancel subscriptions, timers, etc.
  }

  // Element moved to new document (e.g., into iframe)
  adoptedCallback() {
    // Rarely needed
  }

  // Observed attribute changed
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return; // no-op
    this.render(); // re-render on any attribute change
  }

  // Getters/setters for property ↔ attribute reflection
  get variant() { return this.getAttribute('variant') || 'primary'; }
  set variant(v) { this.setAttribute('variant', v); }

  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }

  render() {
    this._shadow.innerHTML = `
      <style>
        :host { display: inline-flex; }
        :host([disabled]) { opacity: 0.5; pointer-events: none; }
        button { /* styles */ }
      </style>
      <button part="button" ?disabled="${this.disabled}">
        <slot></slot>
      </button>
    `;
  }

  _addEventListeners() {
    this._shadow.querySelector('button')?.addEventListener('click', this._handleClick);
  }

  _removeEventListeners() {
    this._shadow.querySelector('button')?.removeEventListener('click', this._handleClick);
  }

  _handleClick = (e) => {
    this.dispatchEvent(new CustomEvent('my-click', {
      bubbles: true,
      composed: true, // cross Shadow DOM boundary
      detail: { variant: this.variant }
    }));
  }
}

customElements.define('my-button', MyButton);

Step 2: Shadow DOM — open vs closed


Step 3: Template and Slot


Step 4: CSS Custom Properties Across Shadow DOM

CSS custom properties DO pierce the Shadow DOM boundary (they inherit through it):


Step 5: Custom Events and Element Communication


Step 6: Form-Associated Custom Elements


Step 7: Upgrading and whenDefined


Step 8: Capstone — jsdom Custom Element Demo

📸 Verified Output:


Summary

Feature
API
Purpose

Define element

customElements.define('tag', Class)

Register custom tag

Shadow root

this.attachShadow({mode:'open'})

Encapsulated DOM

Lifecycle

connectedCallback() etc.

DOM event hooks

Observe attrs

static observedAttributes

React to attr changes

Template

<template> + cloneNode(true)

Reusable HTML

Slots

<slot name="x">

Content projection

Cross-shadow events

composed: true

Event propagation

Style theming

CSS custom properties

Cross-shadow styling

Part styling

::part(name)

External style hooks

Form-associated

static formAssociated = true

Native form integration

Last updated