Custom Elements and Web Components

1. Custom Element Registration and Lifecycle

Element Type Extends Tag Name Use Case
Autonomous Element HTMLElement Must contain hyphen (e.g., my-button) Completely new element with custom behavior
Customized Built-in Specific HTML element (e.g., HTMLButtonElement) Use is attribute on standard element Extend existing element with extra features
Lifecycle Callback When Called Use For
constructor() Element created or upgraded Initialize state, create Shadow DOM, event listeners setup (don't touch attributes/children)
connectedCallback() Element added to DOM Setup, fetch data, start timers, render content (can be called multiple times)
disconnectedCallback() Element removed from DOM Cleanup, remove listeners, cancel timers/requests
attributeChangedCallback() Observed attribute changed React to attribute changes, update UI
adoptedCallback() Element moved to new document Rare - handle document adoption (iframe, document.adoptNode)
Static Property Type Purpose
observedAttributes string[] List of attributes to watch for changes (triggers attributeChangedCallback)

Example: Autonomous custom element with lifecycle

// Define custom element class
class MyCounter extends HTMLElement {
  // Define which attributes to observe
  static get observedAttributes() {
    return ['count', 'step'];
  }
  
  constructor() {
    super(); // Always call first
    
    // Initialize private state
    this._count = 0;
    this._step = 1;
    
    // Create shadow DOM for encapsulation
    this.attachShadow({ mode: 'open' });
    
    // Setup initial structure
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
          padding: 10px;
          border: 2px solid #333;
        }
        button {
          margin: 0 5px;
          padding: 5px 10px;
        }
        .count {
          font-size: 24px;
          font-weight: bold;
        }
      </style>
      <div>
        <button class="decrement">-</button>
        <span class="count">0</span>
        <button class="increment">+</button>
      </div>
    `;
    
    // Store references (in shadow DOM)
    this._countDisplay = this.shadowRoot.querySelector('.count');
    this._incrementBtn = this.shadowRoot.querySelector('.increment');
    this._decrementBtn = this.shadowRoot.querySelector('.decrement');
  }
  
  // Called when element added to DOM
  connectedCallback() {
    console.log('Counter connected to DOM');
    
    // Attach event listeners
    this._incrementBtn.addEventListener('click', this._handleIncrement);
    this._decrementBtn.addEventListener('click', this._handleDecrement);
    
    // Read initial attributes
    if (this.hasAttribute('count')) {
      this._count = parseInt(this.getAttribute('count'));
      this._updateDisplay();
    }
    if (this.hasAttribute('step')) {
      this._step = parseInt(this.getAttribute('step'));
    }
  }
  
  // Called when element removed from DOM
  disconnectedCallback() {
    console.log('Counter disconnected from DOM');
    
    // Clean up event listeners
    this._incrementBtn.removeEventListener('click', this._handleIncrement);
    this._decrementBtn.removeEventListener('click', this._handleDecrement);
  }
  
  // Called when observed attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
    
    if (name === 'count') {
      this._count = parseInt(newValue);
      this._updateDisplay();
    } else if (name === 'step') {
      this._step = parseInt(newValue);
    }
  }
  
  // Private methods (arrow functions to preserve 'this')
  _handleIncrement = () => {
    this._count += this._step;
    this._updateDisplay();
    this._dispatchEvent();
  }
  
  _handleDecrement = () => {
    this._count -= this._step;
    this._updateDisplay();
    this._dispatchEvent();
  }
  
  _updateDisplay() {
    this._countDisplay.textContent = this._count;
  }
  
  _dispatchEvent() {
    // Dispatch custom event
    this.dispatchEvent(new CustomEvent('countchange', {
      detail: { count: this._count },
      bubbles: true,
      composed: true // Allow event to cross shadow DOM boundary
    }));
  }
  
  // Public API (getters/setters)
  get count() {
    return this._count;
  }
  
  set count(value) {
    this.setAttribute('count', value);
  }
  
  get step() {
    return this._step;
  }
  
  set step(value) {
    this.setAttribute('step', value);
  }
  
  // Public methods
  reset() {
    this.count = 0;
  }
}

// Register custom element
customElements.define('my-counter', MyCounter);

// Usage in HTML:
// <my-counter count="5" step="2"></my-counter>

// Usage in JavaScript:
const counter = document.querySelector('my-counter');
counter.addEventListener('countchange', (e) => {
  console.log('Count changed to:', e.detail.count);
});
counter.count = 10; // Set count
counter.reset(); // Call method

Example: Customized built-in element

// Extend existing button element
class FancyButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', this._addRipple);
  }
  
  connectedCallback() {
    this.style.position = 'relative';
    this.style.overflow = 'hidden';
  }
  
  _addRipple = (e) => {
    const ripple = document.createElement('span');
    ripple.classList.add('ripple');
    ripple.style.left = e.offsetX + 'px';
    ripple.style.top = e.offsetY + 'px';
    this.appendChild(ripple);
    
    setTimeout(() => ripple.remove(), 600);
  }
}

// Register as customized built-in (note the 'extends' option)
customElements.define('fancy-button', FancyButton, { extends: 'button' });

// Usage with 'is' attribute:
// <button is="fancy-button">Click Me</button>

// Or create programmatically:
const btn = document.createElement('button', { is: 'fancy-button' });
btn.textContent = 'Click Me';
document.body.appendChild(btn);
Best Practices: Always call super() first in constructor. Don't access attributes or children in constructor - wait for connectedCallback. Always remove event listeners in disconnectedCallback to prevent memory leaks. Use underscore prefix for private properties/methods.

2. Shadow DOM and Encapsulation

Shadow DOM Mode Access from Outside Use Case
open ✅ Accessible via element.shadowRoot Most common - allows external access for testing/styling
closed ❌ Returns null Complete encapsulation (rare, harder to test)
Feature Benefit Example
Style Encapsulation CSS doesn't leak in/out Component styles won't affect page, page styles won't affect component
DOM Encapsulation Internal structure hidden querySelector() from outside can't find shadow DOM elements
:host Selector Style the host element :host { display: block; }
:host() Function Conditional host styling :host(.active) { color: red; }
:host-context() Style based on ancestor :host-context(.dark-theme) { color: white; }
::slotted() Style slotted content ::slotted(p) { margin: 0; }
CSS Custom Properties Behavior Use Case
Inherit into Shadow DOM ✅ Yes Allow external theming via CSS variables
Regular styles ❌ Blocked Component styles are isolated

Example: Shadow DOM with style encapsulation

class StyledCard extends HTMLElement {
  constructor() {
    super();
    
    // Create shadow DOM (open mode)
    const shadow = this.attachShadow({ mode: 'open' });
    
    // Add styles (scoped to shadow DOM)
    shadow.innerHTML = `
      <style>
        /* :host styles the custom element itself */
        :host {
          display: block;
          border: 1px solid var(--card-border, #ddd);
          border-radius: 8px;
          padding: 16px;
          background: var(--card-bg, white);
        }
        
        /* :host() with selector - style host when it has class */
        :host(.featured) {
          border-color: gold;
          border-width: 3px;
        }
        
        /* :host-context() - style based on ancestor */
        :host-context(.dark-theme) {
          background: #333;
          color: white;
        }
        
        /* Regular styles (only affect shadow DOM) */
        h2 {
          margin: 0 0 10px 0;
          color: var(--card-title-color, #333);
        }
        
        p {
          color: #666;
          line-height: 1.5;
        }
        
        /* This won't affect outside elements */
        .button {
          background: blue;
          color: white;
          padding: 8px 16px;
          border: none;
          border-radius: 4px;
        }
      </style>
      
      <div class="card-content">
        <h2><slot name="title">Default Title</slot></h2>
        <p><slot>Default content</slot></p>
        <button class="button">Action</button>
      </div>
    `;
  }
}

customElements.define('styled-card', StyledCard);

// Usage - styles won't leak in or out:
// <style>
//   /* This CSS variable WILL pass through shadow boundary */
//   styled-card {
//     --card-bg: #f0f0f0;
//     --card-title-color: #0066cc;
//   }
//   
//   /* These styles WON'T affect shadow DOM */
//   h2 { color: red; } /* Won't affect h2 in shadow DOM */
//   .button { background: green; } /* Won't affect .button in shadow DOM */
// </style>
//
// <styled-card class="featured">
//   <span slot="title">Card Title</span>
//   This is the card content
// </styled-card>

Example: Access and manipulation

class MyElement extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
    this.shadow.innerHTML = `
      <div class="inner">Shadow content</div>
    `;
  }
}
customElements.define('my-element', MyElement);

const el = document.querySelector('my-element');

// Access shadow DOM (only works with mode: 'open')
console.log(el.shadowRoot); // ShadowRoot object
console.log(el.shadowRoot.querySelector('.inner')); // <div class="inner">

// From outside, can't access shadow DOM with regular queries
console.log(document.querySelector('.inner')); // null (not found)

// With mode: 'closed', shadowRoot would be null
const closedShadow = this.attachShadow({ mode: 'closed' });
console.log(el.shadowRoot); // null
Browser Support: All modern browsers - Shadow DOM is well supported but some CSS pseudo-classes may have limited support. Use feature detection: if ('attachShadow' in Element.prototype). Polyfills available for older browsers.

3. HTML Templates and Content Cloning

Element Purpose Parsed Rendered
<template> Hold inert HTML for cloning ✅ Yes (valid HTML) ❌ No (not in DOM tree)
template.content DocumentFragment containing template contents ✅ Available immediately Only when cloned and appended
Cloning Method Returns Deep Clone Use Case
cloneNode(true) Node (element clone) ✅ Yes (with descendants) Clone single element and children
cloneNode(false) Node (element only) ❌ No (shallow) Clone element without children
importNode(node, true) Node (from another document) ✅ Yes Import from template or iframe

Example: Template with cloning and population

<!-- Define template (not rendered) -->
<template id="card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
      margin: 10px;
    }
    .card-title {
      font-size: 18px;
      font-weight: bold;
      margin-bottom: 8px;
    }
    .card-body {
      color: #666;
    }
  </style>
  <div class="card">
    <div class="card-title"></div>
    <div class="card-body"></div>
    <button class="card-action">Action</button>
  </div>
</template>

<div id="cards-container"></div>

<script>
// Get template
const template = document.getElementById('card-template');

// Create cards from data
const cardsData = [
  { title: 'Card 1', body: 'Content for card 1' },
  { title: 'Card 2', body: 'Content for card 2' },
  { title: 'Card 3', body: 'Content for card 3' }
];

const container = document.getElementById('cards-container');

cardsData.forEach(data => {
  // Clone template content (deep clone)
  const clone = template.content.cloneNode(true);
  
  // Populate cloned content
  clone.querySelector('.card-title').textContent = data.title;
  clone.querySelector('.card-body').textContent = data.body;
  
  // Add event listener
  clone.querySelector('.card-action').addEventListener('click', () => {
    alert(`Action for: ${data.title}`);
  });
  
  // Append to DOM
  container.appendChild(clone);
});
</script>

Example: Template in Web Component

class UserCard extends HTMLElement {
  constructor() {
    super();
    
    // Create shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });
    
    // Create template programmatically
    const template = document.createElement('template');
    template.innerHTML = `
      <style>
        :host {
          display: block;
          border: 2px solid #0066cc;
          border-radius: 8px;
          padding: 16px;
          max-width: 300px;
        }
        .avatar {
          width: 80px;
          height: 80px;
          border-radius: 50%;
          object-fit: cover;
        }
        .name {
          font-size: 20px;
          font-weight: bold;
          margin: 10px 0 5px 0;
        }
        .email {
          color: #666;
        }
      </style>
      <div class="user-card">
        <img class="avatar" alt="User avatar">
        <div class="name"></div>
        <div class="email"></div>
      </div>
    `;
    
    // Clone and append template
    shadow.appendChild(template.content.cloneNode(true));
    
    // Store references
    this._avatar = shadow.querySelector('.avatar');
    this._name = shadow.querySelector('.name');
    this._email = shadow.querySelector('.email');
  }
  
  connectedCallback() {
    this._render();
  }
  
  static get observedAttributes() {
    return ['name', 'email', 'avatar'];
  }
  
  attributeChangedCallback() {
    this._render();
  }
  
  _render() {
    this._name.textContent = this.getAttribute('name') || 'Unknown';
    this._email.textContent = this.getAttribute('email') || '';
    this._avatar.src = this.getAttribute('avatar') || 'default-avatar.png';
  }
}

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

// Usage:
// <user-card 
//   name="John Doe" 
//   email="john@example.com"
//   avatar="john.jpg">
// </user-card>
Template Benefits: Content is parsed but not rendered - efficient for repeated structures. Scripts don't execute, images don't load until cloned and inserted. Can contain any valid HTML including <style> and <script>. Perfect for component blueprints.

4. Slot Elements and Content Projection

Slot Type Attribute Matches Priority
Named Slot <slot name="header"> Elements with slot="header" High - explicit match
Default Slot <slot> (no name) All unslotted content Low - catches remaining content
Fallback Content Content inside <slot> Shown when slot is empty N/A - default
Slot API Returns Description
slot.assignedNodes() Node[] Get nodes assigned to slot (includes text nodes)
slot.assignedNodes({flatten: true}) Node[] Get flattened assigned nodes (including nested slots)
slot.assignedElements() Element[] Get only element nodes (no text nodes)
element.assignedSlot HTMLSlotElement | null Get slot element is assigned to
Slot Event When Fired Event Target
slotchange Slot's assigned nodes change The <slot> element

Example: Named and default slots

// Component definition
class BlogPost extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          overflow: hidden;
        }
        .header {
          background: #0066cc;
          color: white;
          padding: 20px;
        }
        .content {
          padding: 20px;
          line-height: 1.6;
        }
        .footer {
          background: #f5f5f5;
          padding: 10px 20px;
          border-top: 1px solid #ddd;
        }
        /* Style slotted content */
        ::slotted(h1) {
          margin: 0;
          font-size: 24px;
        }
        ::slotted(p) {
          margin: 10px 0;
        }
      </style>
      
      <div class="header">
        <!-- Named slot for title -->
        <slot name="title">Default Title</slot>
        <!-- Named slot for subtitle -->
        <slot name="subtitle"></slot>
      </div>
      
      <div class="content">
        <!-- Default slot for main content -->
        <slot>No content provided</slot>
      </div>
      
      <div class="footer">
        <!-- Named slot for metadata -->
        <slot name="meta"></slot>
      </div>
    `;
  }
}

customElements.define('blog-post', BlogPost);

// HTML Usage:
<blog-post>
  <!-- Content projected into named slots -->
  <h1 slot="title">My Blog Post</h1>
  <span slot="subtitle">A brief introduction</span>
  
  <!-- Content without slot attr goes to default slot -->
  <p>This is the main content of the blog post.</p>
  <p>It can contain multiple paragraphs.</p>
  
  <!-- Another named slot -->
  <div slot="meta">
    <span>Author: John Doe</span>
    <span>Date: 2025-12-22</span>
  </div>
</blog-post>

// Result: Content is projected into respective slots
// - h1 and span appear in header
// - p elements appear in content area
// - div appears in footer

Example: Slot API and slotchange event

class DynamicList extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        .count { 
          font-weight: bold; 
          margin-bottom: 10px; 
        }
        ::slotted(li) {
          padding: 5px;
          border-bottom: 1px solid #ddd;
        }
      </style>
      <div class="count">Items: 0</div>
      <ul>
        <slot></slot>
      </ul>
    `;
    
    this._slot = shadow.querySelector('slot');
    this._countDisplay = shadow.querySelector('.count');
    
    // Listen for slot changes
    this._slot.addEventListener('slotchange', () => {
      this._updateCount();
    });
  }
  
  connectedCallback() {
    this._updateCount();
  }
  
  _updateCount() {
    // Get assigned elements (only <li> elements)
    const items = this._slot.assignedElements();
    this._countDisplay.textContent = `Items: ${items.length}`;
    
    // Log assigned nodes
    console.log('Assigned nodes:', this._slot.assignedNodes());
    console.log('Assigned elements:', items);
    
    // You can also manipulate slotted content
    items.forEach((item, index) => {
      item.setAttribute('data-index', index);
    });
  }
  
  // Public method to get items
  getItems() {
    return this._slot.assignedElements();
  }
}

customElements.define('dynamic-list', DynamicList);

// Usage:
<dynamic-list id="myList">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</dynamic-list>

<script>
const list = document.getElementById('myList');

// Add new item dynamically
const newItem = document.createElement('li');
newItem.textContent = 'Item 4';
list.appendChild(newItem); // Triggers slotchange event

// Get items from component
console.log(list.getItems()); // [li, li, li, li]

// Check which slot an element is in
const firstItem = list.querySelector('li');
console.log(firstItem.assignedSlot); // <slot> element
</script>

Example: Multiple named slots with fallback

class MediaCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: flex; gap: 15px; }
        .media { flex: 0 0 200px; }
        .body { flex: 1; }
        img { width: 100%; height: auto; }
      </style>
      
      <div class="media">
        <!-- Fallback content shown if no image provided -->
        <slot name="image">
          <img src="placeholder.png" alt="Placeholder">
        </slot>
      </div>
      
      <div class="body">
        <slot name="title">
          <h3>Untitled</h3>
        </slot>
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('media-card', MediaCard);

// With image:
<media-card>
  <img slot="image" src="photo.jpg" alt="Photo">
  <h2 slot="title">Card Title</h2>
  <p>Card description</p>
</media-card>

// Without image (shows fallback):
<media-card>
  <h2 slot="title">Card Title</h2>
  <p>Card description</p>
</media-card>
Slot Styling: Use ::slotted(selector) to style slotted content from within shadow DOM. Can only target direct children of the host element. Slotted content maintains its original styles from light DOM. CSS custom properties pierce shadow boundary for theming.

5. Custom Attributes and Properties

Type Syntax Reflected Use Case
HTML Attribute <my-el attr="value"> Always strings Initial configuration, serialization, declarative
JS Property element.prop = value Any type Programmatic API, complex data, methods
Reflected Property Synced attribute ↔ property Both ways Keep HTML and JS in sync
Pattern Implementation Benefits
Getter/Setter get prop() { } set prop(val) { } Validation, side effects, computed values
Boolean Attributes Presence = true, absence = false Match native HTML behavior (disabled, hidden)
Data Attributes data-* for custom data Valid HTML, CSS selectors, dataset API

Example: Reflected properties and boolean attributes

class ToggleSwitch extends HTMLElement {
  // Define observed attributes
  static get observedAttributes() {
    return ['checked', 'disabled', 'label'];
  }
  
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        :host {
          display: inline-block;
          user-select: none;
        }
        .switch {
          display: flex;
          align-items: center;
          gap: 10px;
          cursor: pointer;
        }
        .switch.disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
        .toggle {
          width: 50px;
          height: 24px;
          background: #ccc;
          border-radius: 12px;
          position: relative;
          transition: background 0.3s;
        }
        .toggle.checked {
          background: #4caf50;
        }
        .slider {
          width: 20px;
          height: 20px;
          background: white;
          border-radius: 50%;
          position: absolute;
          top: 2px;
          left: 2px;
          transition: left 0.3s;
        }
        .toggle.checked .slider {
          left: 28px;
        }
      </style>
      
      <div class="switch">
        <div class="toggle">
          <div class="slider"></div>
        </div>
        <span class="label"></span>
      </div>
    `;
    
    this._switch = shadow.querySelector('.switch');
    this._toggle = shadow.querySelector('.toggle');
    this._label = shadow.querySelector('.label');
    
    this._switch.addEventListener('click', this._handleClick);
  }
  
  // BOOLEAN ATTRIBUTE: 'checked'
  get checked() {
    return this.hasAttribute('checked');
  }
  
  set checked(value) {
    if (value) {
      this.setAttribute('checked', '');
    } else {
      this.removeAttribute('checked');
    }
  }
  
  // BOOLEAN ATTRIBUTE: 'disabled'
  get disabled() {
    return this.hasAttribute('disabled');
  }
  
  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
  
  // STRING ATTRIBUTE: 'label'
  get label() {
    return this.getAttribute('label') || '';
  }
  
  set label(value) {
    if (value) {
      this.setAttribute('label', value);
    } else {
      this.removeAttribute('label');
    }
  }
  
  // NUMBER PROPERTY (not reflected to attribute)
  get value() {
    return this._value || 0;
  }
  
  set value(val) {
    this._value = Number(val);
  }
  
  // OBJECT PROPERTY (cannot be attribute)
  get data() {
    return this._data;
  }
  
  set data(obj) {
    this._data = obj;
  }
  
  // Handle attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'checked') {
      this._toggle.classList.toggle('checked', this.checked);
    } else if (name === 'disabled') {
      this._switch.classList.toggle('disabled', this.disabled);
    } else if (name === 'label') {
      this._label.textContent = newValue;
    }
  }
  
  _handleClick = () => {
    if (this.disabled) return;
    
    // Toggle checked state
    this.checked = !this.checked;
    
    // Dispatch event
    this.dispatchEvent(new CustomEvent('toggle', {
      detail: { checked: this.checked },
      bubbles: true
    }));
  }
  
  // Public methods
  toggle() {
    this.checked = !this.checked;
  }
}

customElements.define('toggle-switch', ToggleSwitch);

// HTML Usage:
<toggle-switch 
  checked 
  label="Enable notifications"
  data-user-id="123">
</toggle-switch>

// JavaScript Usage:
const toggle = document.querySelector('toggle-switch');

// Boolean attributes/properties
console.log(toggle.checked); // true
toggle.checked = false; // Removes 'checked' attribute

console.log(toggle.disabled); // false
toggle.disabled = true; // Adds 'disabled' attribute

// String property
toggle.label = 'New label'; // Updates attribute

// Number property (not reflected)
toggle.value = 42;
console.log(toggle.getAttribute('value')); // null (not reflected)

// Object property
toggle.data = { userId: 123, settings: {} };

// Event listener
toggle.addEventListener('toggle', (e) => {
  console.log('Toggled:', e.detail.checked);
});

Example: Data attributes and dataset API

class ProductCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        .card { padding: 20px; border: 1px solid #ddd; }
        .price { font-size: 24px; font-weight: bold; color: #0066cc; }
        .sale { color: #ff0000; }
      </style>
      <div class="card">
        <h3 class="name"></h3>
        <p class="price"></p>
        <button class="buy">Buy Now</button>
      </div>
    `;
  }
  
  connectedCallback() {
    // Access data attributes via dataset
    const name = this.dataset.productName;
    const price = this.dataset.price;
    const onSale = this.dataset.sale !== undefined;
    
    const shadow = this.shadowRoot;
    shadow.querySelector('.name').textContent = name;
    shadow.querySelector('.price').textContent = `${price}`;
    
    if (onSale) {
      shadow.querySelector('.price').classList.add('sale');
    }
    
    shadow.querySelector('.buy').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('purchase', {
        detail: {
          productId: this.dataset.productId,
          name: this.dataset.productName,
          price: this.dataset.price
        }
      }));
    });
  }
}

customElements.define('product-card', ProductCard);

// HTML with data attributes:
<product-card
  data-product-id="12345"
  data-product-name="Laptop"
  data-price="999"
  data-sale>
</product-card>

// JavaScript access:
const card = document.querySelector('product-card');

// Read data attributes
console.log(card.dataset.productId); // "12345"
console.log(card.dataset.productName); // "Laptop"

// Write data attributes
card.dataset.price = "899";
card.dataset.category = "electronics";

// Check existence
if ('sale' in card.dataset) {
  console.log('On sale!');
}
Best Practices: Use attributes for simple, serializable values (strings, numbers, booleans). Use properties for complex data (objects, arrays, functions). Reflect important properties to attributes for CSS selectors and HTML serialization. Follow HTML conventions: boolean attributes (presence = true), lowercase names with hyphens.

6. Web Component Best Practices

Category Best Practice Reason
Naming Use hyphenated names (min 2 words) Required by spec, avoids conflicts with native elements
Encapsulation Always use Shadow DOM Style isolation, implementation hiding
Performance Defer heavy work to connectedCallback Constructor must be lightweight
Cleanup Remove listeners in disconnectedCallback Prevent memory leaks
Events Use CustomEvent with composed: true Allow events to cross shadow boundary
Theming Expose CSS custom properties Enable external styling without breaking encapsulation
Accessibility Add ARIA attributes, keyboard support Screen readers, keyboard navigation
Error Handling Validate inputs, provide fallbacks Graceful degradation, better UX
Anti-Pattern Why Avoid Better Approach
Modifying attributes in constructor Not yet in DOM, parser conflicts Wait for connectedCallback
Accessing children in constructor Children not yet parsed Use connectedCallback or slotchange
Single-word tag names Invalid, conflicts with native elements Use hyphenated names (my-element)
Not calling super() Breaks inheritance chain Always call super() first in constructor
Modifying light DOM from component Breaks user expectations, conflicts Use slots, dispatch events instead
Global styles in component Pollutes global namespace Use Shadow DOM styles or CSS custom properties

Example: Production-ready component with best practices

/**
 * Accessible rating component
 * @element star-rating
 * @attr {number} value - Current rating (0-5)
 * @attr {number} max - Maximum rating (default: 5)
 * @attr {boolean} readonly - Disable interaction
 * @fires rating-change - When rating changes
 * @cssprop --star-size - Size of stars (default: 24px)
 * @cssprop --star-color - Color of filled stars (default: gold)
 */
class StarRating extends HTMLElement {
  static get observedAttributes() {
    return ['value', 'max', 'readonly'];
  }
  
  constructor() {
    super();
    
    // Initialize state
    this._value = 0;
    this._max = 5;
    this._readonly = false;
    
    // Create shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: inline-flex;
          gap: 4px;
          --star-size: 24px;
          --star-color: gold;
          --star-empty: #ddd;
        }
        
        :host([readonly]) {
          pointer-events: none;
        }
        
        .star {
          width: var(--star-size);
          height: var(--star-size);
          cursor: pointer;
          fill: var(--star-empty);
          transition: fill 0.2s;
        }
        
        .star.filled {
          fill: var(--star-color);
        }
        
        .star:hover,
        .star.preview {
          fill: var(--star-color);
          opacity: 0.7;
        }
        
        :host([readonly]) .star {
          cursor: default;
        }
        
        /* Focus styles for accessibility */
        .star:focus {
          outline: 2px solid #0066cc;
          outline-offset: 2px;
        }
      </style>
      <div class="stars" role="radiogroup" aria-label="Rating"></div>
    `;
    
    this._starsContainer = shadow.querySelector('.stars');
  }
  
  connectedCallback() {
    // Read attributes
    this._value = parseFloat(this.getAttribute('value')) || 0;
    this._max = parseInt(this.getAttribute('max')) || 5;
    this._readonly = this.hasAttribute('readonly');
    
    // Build UI
    this._render();
    
    // Add event listeners (if not readonly)
    if (!this._readonly) {
      this._starsContainer.addEventListener('click', this._handleClick);
      this._starsContainer.addEventListener('mouseover', this._handleHover);
      this._starsContainer.addEventListener('mouseout', this._handleMouseOut);
      this._starsContainer.addEventListener('keydown', this._handleKeydown);
    }
  }
  
  disconnectedCallback() {
    // Clean up event listeners
    this._starsContainer.removeEventListener('click', this._handleClick);
    this._starsContainer.removeEventListener('mouseover', this._handleHover);
    this._starsContainer.removeEventListener('mouseout', this._handleMouseOut);
    this._starsContainer.removeEventListener('keydown', this._handleKeydown);
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) return;
    
    switch(name) {
      case 'value':
        this._value = parseFloat(newValue) || 0;
        this._updateStars();
        break;
      case 'max':
        this._max = parseInt(newValue) || 5;
        this._render();
        break;
      case 'readonly':
        this._readonly = this.hasAttribute('readonly');
        break;
    }
  }
  
  // Render stars
  _render() {
    this._starsContainer.innerHTML = '';
    
    for (let i = 1; i <= this._max; i++) {
      const star = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      star.setAttribute('class', 'star');
      star.setAttribute('viewBox', '0 0 24 24');
      star.setAttribute('data-value', i);
      star.setAttribute('role', 'radio');
      star.setAttribute('aria-checked', i <= this._value);
      star.setAttribute('tabindex', i === Math.ceil(this._value) ? 0 : -1);
      
      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('d', 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z');
      
      star.appendChild(path);
      this._starsContainer.appendChild(star);
    }
    
    this._updateStars();
  }
  
  // Update visual state
  _updateStars() {
    const stars = this._starsContainer.querySelectorAll('.star');
    stars.forEach((star, index) => {
      const value = index + 1;
      star.classList.toggle('filled', value <= this._value);
      star.setAttribute('aria-checked', value <= this._value);
    });
  }
  
  // Event handlers
  _handleClick = (e) => {
    const star = e.target.closest('.star');
    if (!star) return;
    
    const value = parseInt(star.dataset.value);
    this.value = value;
  }
  
  _handleHover = (e) => {
    const star = e.target.closest('.star');
    if (!star) return;
    
    const value = parseInt(star.dataset.value);
    const stars = this._starsContainer.querySelectorAll('.star');
    
    stars.forEach((s, index) => {
      s.classList.toggle('preview', index < value);
    });
  }
  
  _handleMouseOut = () => {
    const stars = this._starsContainer.querySelectorAll('.star');
    stars.forEach(s => s.classList.remove('preview'));
  }
  
  _handleKeydown = (e) => {
    let newValue;
    
    switch(e.key) {
      case 'ArrowRight':
      case 'ArrowUp':
        newValue = Math.min(this._value + 1, this._max);
        break;
      case 'ArrowLeft':
      case 'ArrowDown':
        newValue = Math.max(this._value - 1, 0);
        break;
      case 'Home':
        newValue = 0;
        break;
      case 'End':
        newValue = this._max;
        break;
      default:
        return;
    }
    
    e.preventDefault();
    this.value = newValue;
    
    // Update focus
    const stars = this._starsContainer.querySelectorAll('.star');
    stars[newValue - 1]?.focus();
  }
  
  // Public API
  get value() {
    return this._value;
  }
  
  set value(val) {
    const newValue = Math.max(0, Math.min(val, this._max));
    if (newValue === this._value) return;
    
    const oldValue = this._value;
    this._value = newValue;
    this.setAttribute('value', newValue);
    
    // Dispatch event
    this.dispatchEvent(new CustomEvent('rating-change', {
      detail: { value: newValue, oldValue },
      bubbles: true,
      composed: true // Cross shadow boundary
    }));
  }
  
  get max() {
    return this._max;
  }
  
  set max(val) {
    this.setAttribute('max', val);
  }
  
  get readonly() {
    return this._readonly;
  }
  
  set readonly(val) {
    if (val) {
      this.setAttribute('readonly', '');
    } else {
      this.removeAttribute('readonly');
    }
  }
}

customElements.define('star-rating', StarRating);

// Usage:
<star-rating 
  value="3.5" 
  max="5"
  style="--star-size: 32px; --star-color: #ff9800">
</star-rating>

<script>
const rating = document.querySelector('star-rating');

rating.addEventListener('rating-change', (e) => {
  console.log('New rating:', e.detail.value);
});

// Make readonly after selection
rating.addEventListener('rating-change', () => {
  rating.readonly = true;
});
</script>

Section 12 Key Takeaways

  • Custom element names must contain hyphen (my-element); autonomous extend HTMLElement, customized built-ins extend specific elements
  • Constructor initializes state; connectedCallback for DOM manipulation; disconnectedCallback for cleanup
  • Shadow DOM provides style and DOM encapsulation; use mode: 'open' for testability
  • :host styles the custom element; ::slotted() styles projected content; CSS custom properties pierce shadow boundary
  • <template> content is parsed but not rendered; perfect for component blueprints and repeated structures
  • Named slots (<slot name="x">) for specific content; default slot for remaining; slotchange event for dynamic updates
  • Reflect important properties to attributes for CSS selectors; use properties for complex data
  • Boolean attributes: presence = true (checked, disabled); use dataset for custom data
  • Always call super() first; validate inputs; dispatch CustomEvent with composed: true to cross shadow boundary
  • Follow accessibility: ARIA attributes, keyboard navigation, focus management, semantic HTML