Web Components and Custom Elements APIs

1. Custom Elements Registration and Lifecycle

Method Description Browser Support
customElements.define(name, constructor, options) Registers custom element. Name must contain hyphen. Options: extends for customized built-ins. All Modern Browsers
customElements.get(name) Gets constructor for custom element. Returns undefined if not defined. All Modern Browsers
customElements.whenDefined(name) Returns Promise that resolves when element is defined. All Modern Browsers
customElements.upgrade(root) Upgrades custom elements in subtree that were created before definition. All Modern Browsers
Lifecycle Callback When Called Use Case
constructor() Element instance created. Called before attached to DOM. Initialize state, create shadow root
connectedCallback() Element inserted into DOM. Can be called multiple times. Setup, add event listeners, fetch data
disconnectedCallback() Element removed from DOM. Cleanup, remove listeners, cancel requests
attributeChangedCallback(name, oldValue, newValue) Observed attribute changed. React to attribute changes
adoptedCallback() Element moved to new document. Update references (rare)
Static Property Type Description
static observedAttributes string[] Array of attribute names to observe. Required for attributeChangedCallback.

Example: Basic custom element

// Define custom element
class MyElement extends HTMLElement {
  constructor() {
    super();
    console.log("Constructor called");
    
    // Initialize state
    this.count = 0;
    
    // Create shadow DOM
    this.attachShadow({ "mode": "open" });
    this.render();
  }
  
  // Lifecycle: element added to DOM
  connectedCallback() {
    console.log("Connected to DOM");
    
    // Add event listeners
    this.addEventListener("click", this.handleClick);
    
    // Can access attributes here
    console.log("Initial count:", this.getAttribute("count"));
  }
  
  // Lifecycle: element removed from DOM
  disconnectedCallback() {
    console.log("Disconnected from DOM");
    
    // Remove event listeners
    this.removeEventListener("click", this.handleClick);
  }
  
  // Lifecycle: attribute changed
  static get observedAttributes() {
    return ["count", "disabled"];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed: ${oldValue} → ${newValue}`);
    
    if (name === "count") {
      this.count = parseInt(newValue) || 0;
      this.render();
    }
  }
  
  // Methods
  handleClick = () => {
    this.count++;
    this.setAttribute("count", this.count);
  };
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button { padding: 10px 20px; font-size: 16px; }
      </style>
      <button>Count: ${this.count}</button>
    `;
  }
}

// Register custom element (name must have hyphen)
customElements.define("my-element", MyElement);

// Use in HTML: <my-element count="5"></my-element>

// Check if defined
const isDefined = customElements.get("my-element");
console.log("Defined:", isDefined !== undefined);

// Wait for definition
customElements.whenDefined("my-element").then(() => {
  console.log("my-element is defined");
  
  // Create programmatically
  const el = document.createElement("my-element");
  el.setAttribute("count", "10");
  document.body.appendChild(el);
});

Example: Customized built-in element

// Extend built-in element (not supported in Safari)
class FancyButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener("click", this.handleClick);
  }
  
  handleClick() {
    this.style.transform = "scale(0.95)";
    setTimeout(() => {
      this.style.transform = "";
    }, 100);
  }
}

// Register with extends option
customElements.define("fancy-button", FancyButton, { "extends": "button" });

// Use with "is" attribute: <button is="fancy-button">Click me</button>

// Create programmatically
const btn = document.createElement("button", { "is": "fancy-button" });
btn.textContent = "Fancy";
document.body.appendChild(btn);
Note: Custom element names must contain hyphen (e.g., "my-element" not "myelement"). Constructor runs before element attached to DOM - don't access children or attributes there. Use connectedCallback for setup. Customized built-ins not supported in Safari.
Warning: Don't call super() without arguments in constructor. Don't add attributes in constructor - use connectedCallback. connectedCallback can be called multiple times if element moved. Always cleanup in disconnectedCallback.

2. Shadow DOM Encapsulation and Styling

Method/Property Description Browser Support
element.attachShadow(options) Creates shadow root. Options: mode ("open" or "closed"), delegatesFocus. All Modern Browsers
element.shadowRoot Returns shadow root if mode is "open", null if "closed". All Modern Browsers
shadowRoot.mode "open" or "closed". Determines if shadowRoot accessible from outside. All Modern Browsers
shadowRoot.host Returns host element of shadow root. All Modern Browsers
Shadow DOM Feature Description Benefit
Style Encapsulation Styles inside shadow DOM don't affect outside, and vice versa. Prevent CSS conflicts
DOM Encapsulation Shadow DOM not accessible via querySelector from outside. Implementation hiding
Event Retargeting Events from shadow DOM appear to come from host element. Maintain encapsulation
CSS Pseudo-elements/Selectors Description Use Case
:host Selects shadow host from inside shadow DOM. Style host element
:host(selector) Selects host if it matches selector. Conditional host styling
:host-context(selector) Selects host if ancestor matches selector. Theme-based styling
::slotted(selector) Selects slotted content. Style projected content
::part(name) Selects element with part attribute from outside. Allow external styling of parts

Example: Shadow DOM with styling

class StyledCard extends HTMLElement {
  constructor() {
    super();
    
    // Create shadow DOM (mode: "open" for accessibility)
    const shadow = this.attachShadow({ "mode": "open" });
    
    shadow.innerHTML = `
      <style>
        /* Styles scoped to shadow DOM */
        :host {
          display: block;
          border: 2px solid #ccc;
          border-radius: 8px;
          padding: 20px;
          background: white;
        }
        
        /* Style host when has .featured class */
        :host(.featured) {
          border-color: gold;
          border-width: 4px;
        }
        
        /* Style host when disabled attribute present */
        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }
        
        /* Style host based on ancestor */
        :host-context(.dark-theme) {
          background: #333;
          color: white;
          border-color: #666;
        }
        
        h2 {
          margin: 0 0 10px 0;
          color: #333;
        }
        
        /* Expose styling hook with ::part */
        .title {
          font-size: 24px;
        }
        
        .content {
          color: #666;
        }
      </style>
      
      <div class="card">
        <h2 class="title" part="title">
          <slot name="title">Default Title</slot>
        </h2>
        <div class="content" part="content">
          <slot>Default content</slot>
        </div>
      </div>
    `;
  }
}

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

// Usage in HTML:
// <styled-card class="featured">
//   <span slot="title">My Title</span>
//   <p>Card content here</p>
// </styled-card>

// Style parts from outside:
// styled-card::part(title) { color: blue; }
// styled-card::part(content) { font-size: 14px; }

// Closed shadow DOM (shadowRoot not accessible)
class PrivateComponent extends HTMLElement {
  constructor() {
    super();
    
    // mode: "closed" - element.shadowRoot will be null
    const shadow = this.attachShadow({ "mode": "closed" });
    
    // Keep reference if needed internally
    this._shadowRoot = shadow;
    
    shadow.innerHTML = `<p>Private implementation</p>`;
  }
}

customElements.define("private-component", PrivateComponent);
Note: Use mode: "open" for better accessibility and debugging. Shadow DOM styles don't inherit from outside except inheritable properties (color, font-family, etc.). Use ::part() to expose styling hooks. :host-context() has limited browser support.
Warning: Not all elements can have shadow DOM (e.g., <input>, <img>). Closed shadow DOM still accessible via DevTools - not security feature. Events from shadow DOM appear to originate from host (event retargeting).

3. HTML Templates and Template Element

Element/Property Description Browser Support
<template> HTML element that holds client-side content not rendered on page load. All Browsers
template.content Returns DocumentFragment containing template's DOM subtree. All Browsers
document.importNode(node, deep) Clones node from another document. Use with template content. All Browsers

Example: Template element usage

// HTML template
// <template id="card-template">
//   <style>
//     .card { border: 1px solid #ccc; padding: 20px; }
//   </style>
//   <div class="card">
//     <h3 class="title"></h3>
//     <p class="description"></p>
//   </div>
// </template>

// Use template in custom element
class CardElement extends HTMLElement {
  constructor() {
    super();
    
    // Get template
    const template = document.getElementById("card-template");
    
    // Clone template content
    const content = template.content.cloneNode(true);
    
    // Attach to shadow DOM
    this.attachShadow({ "mode": "open" });
    this.shadowRoot.appendChild(content);
  }
  
  connectedCallback() {
    // Populate from attributes
    const title = this.getAttribute("title");
    const description = this.getAttribute("description");
    
    this.shadowRoot.querySelector(".title").textContent = title;
    this.shadowRoot.querySelector(".description").textContent = description;
  }
}

customElements.define("card-element", CardElement);

// Template-based component factory
class TemplateComponent extends HTMLElement {
  constructor() {
    super();
    
    const shadow = this.attachShadow({ "mode": "open" });
    
    // Define template inline
    const template = document.createElement("template");
    template.innerHTML = `
      <style>
        :host { display: block; }
        .container { padding: 20px; background: #f5f5f5; }
      </style>
      <div class="container">
        <slot></slot>
      </div>
    `;
    
    // Clone and append
    shadow.appendChild(template.content.cloneNode(true));
  }
}

customElements.define("template-component", TemplateComponent);

// Reusable template pattern
const createTemplate = (html) => {
  const template = document.createElement("template");
  template.innerHTML = html;
  return template;
};

const buttonTemplate = createTemplate(`
  <style>
    button {
      padding: 10px 20px;
      background: #007bff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    button:hover { background: #0056b3; }
  </style>
  <button><slot>Click me</slot></button>
`);

class CustomButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ "mode": "open" });
    shadow.appendChild(buttonTemplate.content.cloneNode(true));
  }
}

customElements.define("custom-button", CustomButton);
Note: Template content is inert - scripts don't run, images don't load, styles don't apply until cloned into document. Use cloneNode(true) to deep clone template content. Templates can be defined in HTML or created programmatically.

4. Slot API for Content Projection

Element/Attribute Description Use Case
<slot> Placeholder for projected content from light DOM. Content projection
<slot name="..."> Named slot for specific content. Multiple projection points
slot="name" Attribute on light DOM element to target named slot. Target specific slot
Slot Method/Property Description
slot.assignedNodes(options) Returns array of nodes assigned to slot. Options: flatten (include nested slots).
slot.assignedElements(options) Returns array of elements (not text nodes) assigned to slot.
element.assignedSlot Returns <slot> element that element is assigned to.
Slot Event When Fired Use Case
slotchange Assigned nodes of slot change. React to content changes

Example: Slots for content projection

// Custom element with slots
class UserCard extends HTMLElement {
  constructor() {
    super();
    
    const shadow = this.attachShadow({ "mode": "open" });
    
    shadow.innerHTML = `
      <style>
        .card { border: 1px solid #ccc; padding: 20px; }
        .header { display: flex; align-items: center; gap: 10px; }
        .avatar { width: 50px; height: 50px; border-radius: 50%; }
        .name { font-size: 20px; font-weight: bold; }
        .bio { margin-top: 10px; color: #666; }
        .actions { margin-top: 15px; }
      </style>
      
      <div class="card">
        <div class="header">
          <!-- Named slot for avatar -->
          <slot name="avatar">
            <div class="avatar">👤</div>
          </slot>
          
          <!-- Named slot for name -->
          <div class="name">
            <slot name="name">Anonymous</slot>
          </div>
        </div>
        
        <!-- Default slot for bio -->
        <div class="bio">
          <slot>No bio provided</slot>
        </div>
        
        <!-- Named slot for actions -->
        <div class="actions">
          <slot name="actions"></slot>
        </div>
      </div>
    `;
    
    // Listen for slot changes
    shadow.querySelectorAll("slot").forEach(slot => {
      slot.addEventListener("slotchange", (e) => {
        console.log("Slot changed:", e.target.name || "default");
        console.log("Assigned nodes:", e.target.assignedNodes());
      });
    });
  }
}

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

// Usage:
// <user-card>
//   <img slot="avatar" src="avatar.jpg" class="avatar">
//   <span slot="name">John Doe</span>
//   <p>Software developer with passion for web technologies.</p>
//   <div slot="actions">
//     <button>Follow</button>
//     <button>Message</button>
//   </div>
// </user-card>

// Access slotted content
class SlotObserver extends HTMLElement {
  constructor() {
    super();
    
    const shadow = this.attachShadow({ "mode": "open" });
    shadow.innerHTML = `
      <style>::slotted(h2) { color: blue; }</style>
      <slot></slot>
    `;
    
    const slot = shadow.querySelector("slot");
    
    // Get assigned nodes
    slot.addEventListener("slotchange", () => {
      const nodes = slot.assignedNodes();
      console.log("Assigned nodes:", nodes);
      
      // Only elements (not text nodes)
      const elements = slot.assignedElements();
      console.log("Assigned elements:", elements);
      
      // Flatten nested slots
      const flattenedNodes = slot.assignedNodes({ "flatten": true });
      console.log("Flattened:", flattenedNodes);
    });
  }
  
  connectedCallback() {
    // Access assigned slot from light DOM
    const children = this.children;
    Array.from(children).forEach(child => {
      console.log("Element's assigned slot:", child.assignedSlot);
    });
  }
}

customElements.define("slot-observer", SlotObserver);
Note: Slots enable content projection - light DOM content rendered in shadow DOM. Unnamed slots are default slots. Multiple elements can target same named slot. Use ::slotted() to style slotted content from shadow DOM.
Warning: ::slotted() can only select direct children, not descendants. Slotted elements remain in light DOM - only rendered location changes. Slot fallback content shown when no content assigned to slot.

5. Element Internals for Form Integration

Method/Property Description Browser Support
element.attachInternals() Returns ElementInternals object for form/accessibility integration. All Modern Browsers
static formAssociated = true Marks element as form-associated. Required for form integration. All Modern Browsers
ElementInternals Property/Method Description
form Returns associated <form> element.
setFormValue(value, state) Sets element's form value. Optional state for internal representation.
setValidity(flags, message, anchor) Sets custom validity state. Flags object, validation message, anchor element.
willValidate true if element participates in form validation.
validity Returns ValidityState object.
validationMessage Returns validation message string.
checkValidity() Checks validity, fires invalid event if invalid.
reportValidity() Checks validity and reports to user.
Form Lifecycle Callback When Called
formAssociatedCallback(form) Element associated with form.
formDisabledCallback(disabled) Element's disabled state changed via <fieldset>.
formResetCallback() Form reset.
formStateRestoreCallback(state, mode) Browser restores state. Mode: "restore" or "autocomplete".

Example: Form-associated custom element

// Custom input element with form integration
class CustomInput extends HTMLElement {
  // Must set formAssociated to true
  static formAssociated = true;
  
  constructor() {
    super();
    
    // Attach internals for form integration
    this._internals = this.attachInternals();
    
    const shadow = this.attachShadow({ "mode": "open" });
    shadow.innerHTML = `
      <style>
        input {
          padding: 8px;
          border: 1px solid #ccc;
          border-radius: 4px;
        }
        input:invalid { border-color: red; }
        .error { color: red; font-size: 12px; }
      </style>
      <input type="text" />
      <div class="error"></div>
    `;
    
    this._input = shadow.querySelector("input");
    this._error = shadow.querySelector(".error");
    
    // Sync internal input with form value
    this._input.addEventListener("input", () => {
      this.value = this._input.value;
    });
  }
  
  // Form lifecycle callbacks
  formAssociatedCallback(form) {
    console.log("Associated with form:", form);
  }
  
  formResetCallback() {
    console.log("Form reset");
    this.value = "";
  }
  
  formDisabledCallback(disabled) {
    console.log("Disabled:", disabled);
    this._input.disabled = disabled;
  }
  
  formStateRestoreCallback(state, mode) {
    console.log("State restore:", state, mode);
    this.value = state;
  }
  
  // Value getter/setter
  get value() {
    return this._input.value;
  }
  
  set value(val) {
    this._input.value = val;
    
    // Update form value
    this._internals.setFormValue(val);
    
    // Validate
    this.validate();
  }
  
  // Validation
  validate() {
    const value = this.value;
    
    if (this.hasAttribute("required") && !value) {
      this._internals.setValidity(
        { "valueMissing": true },
        "This field is required",
        this._input
      );
      this._error.textContent = "This field is required";
      return false;
    }
    
    const minLength = parseInt(this.getAttribute("minlength")) || 0;
    if (value.length < minLength) {
      this._internals.setValidity(
        { "tooShort": true },
        `Minimum ${minLength} characters required`,
        this._input
      );
      this._error.textContent = `Minimum ${minLength} characters required`;
      return false;
    }
    
    // Valid
    this._internals.setValidity({});
    this._error.textContent = "";
    return true;
  }
  
  // Standard form methods
  checkValidity() {
    return this._internals.checkValidity();
  }
  
  reportValidity() {
    return this._internals.reportValidity();
  }
  
  // Access to form
  get form() {
    return this._internals.form;
  }
  
  get validity() {
    return this._internals.validity;
  }
  
  get validationMessage() {
    return this._internals.validationMessage;
  }
}

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

// Usage:
// <form>
//   <custom-input name="username" required minlength="3"></custom-input>
//   <button type="submit">Submit</button>
// </form>

// Access form value
const form = document.querySelector("form");
form.addEventListener("submit", (e) => {
  e.preventDefault();
  
  const formData = new FormData(form);
  console.log("Username:", formData.get("username"));
});
Note: Set static formAssociated = true to enable form integration. Use attachInternals() to get ElementInternals object. Custom elements integrate with native form features - validation, FormData, submit events. Use setFormValue() to sync with form.
Warning: Can only call attachInternals() once. Must set formAssociated before element registered. Form callbacks may not fire in all scenarios - test thoroughly. ElementInternals has good browser support but check for older browsers.

6. Adoptable Stylesheets for Shadow DOM

Method/Property Description Browser Support
new CSSStyleSheet() Creates new stylesheet object (constructable). All Modern Browsers
sheet.replace(css) Replaces stylesheet content. Returns Promise. Async. All Modern Browsers
sheet.replaceSync(css) Replaces stylesheet content synchronously. All Modern Browsers
document.adoptedStyleSheets Array of stylesheets for document. All Modern Browsers
shadowRoot.adoptedStyleSheets Array of stylesheets for shadow root. All Modern Browsers

Example: Adoptable stylesheets

// Create shared stylesheet
const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
  .card {
    border: 1px solid #ccc;
    border-radius: 8px;
    padding: 20px;
    background: white;
  }
  
  .card:hover {
    box-shadow: 0 4px 8px rgba(0,0,0,0.1);
  }
  
  h2 { margin: 0 0 10px 0; }
  p { color: #666; }
`);

// Use in multiple components
class Card1 extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ "mode": "open" });
    
    // Adopt shared stylesheet
    shadow.adoptedStyleSheets = [sharedStyles];
    
    shadow.innerHTML = `
      <div class="card">
        <h2>Card 1</h2>
        <p>Content</p>
      </div>
    `;
  }
}

class Card2 extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ "mode": "open" });
    
    // Reuse same stylesheet (no duplication)
    shadow.adoptedStyleSheets = [sharedStyles];
    
    shadow.innerHTML = `
      <div class="card">
        <h2>Card 2</h2>
        <p>Content</p>
      </div>
    `;
  }
}

customElements.define("card-1", Card1);
customElements.define("card-2", Card2);

// Multiple stylesheets
const baseStyles = new CSSStyleSheet();
baseStyles.replaceSync(`:host { display: block; }`);

const themeStyles = new CSSStyleSheet();
themeStyles.replaceSync(`
  .dark { background: #333; color: white; }
`);

class ThemedComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ "mode": "open" });
    
    // Use multiple stylesheets
    shadow.adoptedStyleSheets = [baseStyles, themeStyles];
    
    shadow.innerHTML = `<div class="dark">Themed</div>`;
  }
}

customElements.define("themed-component", ThemedComponent);

// Dynamic stylesheet updates
const dynamicStyles = new CSSStyleSheet();
dynamicStyles.replaceSync(`.text { color: blue; }`);

class DynamicStyled extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ "mode": "open" });
    shadow.adoptedStyleSheets = [dynamicStyles];
    shadow.innerHTML = `<p class="text">Dynamic</p>`;
  }
  
  setColor(color) {
    // Update shared stylesheet (affects all instances)
    dynamicStyles.replaceSync(`.text { color: ${color}; }`);
  }
}

customElements.define("dynamic-styled", DynamicStyled);

// Async stylesheet loading
async function loadStylesheet(url) {
  const response = await fetch(url);
  const css = await response.text();
  
  const sheet = new CSSStyleSheet();
  await sheet.replace(css);
  
  return sheet;
}

// Usage
const externalStyles = await loadStylesheet("/styles/component.css");

class StyledFromExternal extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ "mode": "open" });
    shadow.adoptedStyleSheets = [externalStyles];
    shadow.innerHTML = `<div>Styled from external CSS</div>`;
  }
}

customElements.define("styled-from-external", StyledFromExternal);
Note: Adoptable Stylesheets enable stylesheet reuse across shadow roots - better performance than duplicating <style> tags. Stylesheets created once, shared across components. Use replaceSync() for initial load, replace() for async updates.
Warning: Changes to shared stylesheet affect all components using it - be careful with dynamic updates. adoptedStyleSheets replaces array - use spread to add: [...shadow.adoptedStyleSheets, newSheet]. Constructable stylesheets not available in older browsers.

Web Components Best Practices

  • Always use hyphenated names for custom elements - required by spec
  • Use mode: "open" for shadow DOM - better accessibility and debugging
  • Initialize in constructor, setup in connectedCallback
  • Always cleanup in disconnectedCallback - remove listeners, cancel timers
  • Use attributeChangedCallback with observedAttributes for reactive attributes
  • Use templates to avoid creating DOM strings repeatedly
  • Use slots for content projection - more flexible than imperative DOM manipulation
  • Use ::part() to expose styling hooks for component consumers
  • Set formAssociated = true for form-integrated custom elements
  • Use adoptable stylesheets to share styles across components efficiently
  • Avoid customized built-ins - not supported in Safari
  • Test components in isolation and with various content projections