CrackedRuby CrackedRuby

Overview

Web Components represent a suite of browser standards that enable developers to create reusable custom HTML elements with encapsulated functionality and styling. The specification consists of three main technologies: Custom Elements for defining new HTML tags, Shadow DOM for style and markup encapsulation, and HTML Templates for declaring reusable markup fragments. These technologies work together to provide a framework-agnostic approach to component-based web development.

The Web Components standards emerged from the need for native browser support for component architecture without requiring external frameworks. Unlike library-specific component systems, Web Components operate at the browser level, making them interoperable across different JavaScript frameworks and vanilla JavaScript applications. A Web Component created in one context functions identically when imported into applications using React, Vue, Angular, or no framework at all.

The core value proposition centers on encapsulation and reusability. Shadow DOM provides true style isolation, preventing CSS from leaking in or out of components. Custom Elements define component behavior through standard JavaScript classes that extend HTMLElement. HTML Templates offer inert markup storage that can be cloned and activated when needed. These capabilities combine to create self-contained components that bundle HTML, CSS, and JavaScript into single reusable units.

// Basic Web Component structure
class SimpleGreeting extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        p { color: blue; }
      </style>
      <p>Hello, ${this.getAttribute('name') || 'World'}!</p>
    `;
  }
}

customElements.define('simple-greeting', SimpleGreeting);
// Usage: <simple-greeting name="Alice"></simple-greeting>

Key Principles

Web Components architecture rests on four foundational technologies that work in concert. Custom Elements provide the API for defining new HTML elements with custom behavior. Shadow DOM creates encapsulated DOM subtrees isolated from the main document. HTML Templates define markup patterns that remain inert until cloned. ES Modules handle component distribution and dependency management.

Custom Elements define component behavior through JavaScript classes that extend HTMLElement or its descendants. The Custom Elements Registry manages element definitions, ensuring each custom element name maps to exactly one class definition. Custom element names must contain a hyphen to distinguish them from standard HTML elements and prevent naming conflicts with future HTML specifications. The browser automatically instantiates custom element classes when parsing HTML or creating elements via JavaScript.

Shadow DOM establishes a boundary between component internals and the surrounding document. Styles defined within shadow roots remain isolated, preventing unintended inheritance or conflicts. Shadow DOM supports two modes: open shadows expose their content to JavaScript, while closed shadows hide internal structure. The shadow boundary creates a new scope for CSS selectors, IDs, and JavaScript queries. Elements inside shadow roots remain inaccessible to document-level selectors like document.querySelector, maintaining encapsulation.

// Shadow DOM encapsulation demonstration
class StyledButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        /* This styling only affects content inside shadow root */
        button {
          background: linear-gradient(to right, #667eea, #764ba2);
          color: white;
          border: none;
          padding: 12px 24px;
          border-radius: 4px;
          cursor: pointer;
        }
        button:hover {
          opacity: 0.9;
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}

customElements.define('styled-button', StyledButton);

HTML Templates store markup fragments that can be cloned multiple times without parsing overhead. Template content remains inert - scripts don't execute, images don't load, and styles don't apply until the template is cloned into an active document. The <template> element's content property returns a DocumentFragment containing the template's children. Cloning templates is more efficient than parsing HTML strings repeatedly, especially for components that instantiate multiple times.

Lifecycle Callbacks provide hooks into element lifecycle stages. The connectedCallback fires when the element inserts into the DOM, appropriate for initialization and setup. The disconnectedCallback triggers on removal, handling cleanup and resource disposal. The attributeChangedCallback responds to observed attribute changes, enabling reactive behavior. The adoptedCallback executes when moving elements between documents, relevant in iframe scenarios.

// Complete lifecycle demonstration
class LifecycleDemo extends HTMLElement {
  static get observedAttributes() {
    return ['status', 'count'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    console.log('Constructor: element created');
  }
  
  connectedCallback() {
    console.log('Connected: element added to DOM');
    this.render();
  }
  
  disconnectedCallback() {
    console.log('Disconnected: element removed from DOM');
    this.cleanup();
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} changed: ${oldValue}${newValue}`);
    if (this.shadowRoot) {
      this.render();
    }
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <div>Status: ${this.getAttribute('status') || 'unknown'}</div>
      <div>Count: ${this.getAttribute('count') || '0'}</div>
    `;
  }
  
  cleanup() {
    // Remove event listeners, timers, etc.
  }
}

customElements.define('lifecycle-demo', LifecycleDemo);

Slots distribute content from the light DOM into shadow DOM positions. Named slots target specific content via slot attributes, while unnamed slots accept all unassigned content. Slotted content remains in the light DOM but renders at slot positions within the shadow tree. This mechanism preserves the component's style encapsulation while allowing content customization. The slotchange event fires when slot content changes, enabling components to react to content updates.

Ruby Implementation

Ruby web applications integrate Web Components through server-side rendering, asset management, and component generation patterns. While Web Components execute client-side in browsers, Ruby frameworks provide infrastructure for delivering, managing, and sometimes generating component code. The server remains responsible for initial HTML delivery, asset compilation, and data serialization that components consume.

Rails applications typically serve Web Components through the asset pipeline or Webpacker. Component definitions reside in JavaScript files loaded via javascript_include_tag or module scripts. The Sprockets asset pipeline concatenates and minifies component code, while Webpacker provides modern module bundling with code splitting. Rails views instantiate components by rendering custom element tags, passing initial data through attributes or inline JSON.

# Rails helper for rendering Web Components
module WebComponentsHelper
  def web_component(tag_name, attributes = {}, &block)
    attrs = attributes.map { |k, v| "#{k.to_s.dasherize}=\"#{ERB::Util.html_escape(v)}\"" }.join(' ')
    content = capture(&block) if block_given?
    
    tag_string = if content
      "<#{tag_name} #{attrs}>#{content}</#{tag_name}>"
    else
      "<#{tag_name} #{attrs}></#{tag_name}>"
    end
    
    raw(tag_string)
  end
end

# Usage in view
<%= web_component('user-profile', 
                   user_id: @user.id, 
                   theme: 'dark') do %>
  <div slot="header">
    <%= @user.name %>
  </div>
<% end %>

ViewComponent patterns in Rails parallel Web Components concepts while operating server-side. Both architectures emphasize encapsulation, reusability, and composition. ViewComponents render HTML fragments with isolated logic, while Web Components add client-side interactivity and style encapsulation. Applications can combine approaches: ViewComponents generate initial markup that Web Components enhance progressively. The server renders semantic HTML that functions without JavaScript, then Web Components augment with dynamic behavior.

# ViewComponent that generates Web Component markup
class ProfileCardComponent < ViewComponent::Base
  def initialize(user:, interactive: true)
    @user = user
    @interactive = interactive
  end
  
  def call
    if @interactive
      tag.send('user-profile-card', 
               'user-id': @user.id,
               'avatar-url': @user.avatar_url,
               data: { controller: 'profile' }) do
        # Fallback content for progressive enhancement
        render_fallback
      end
    else
      render_fallback
    end
  end
  
  private
  
  def render_fallback
    tag.div(class: 'profile-card') do
      concat tag.img(src: @user.avatar_url, alt: @user.name)
      concat tag.h3(@user.name)
      concat tag.p(@user.bio)
    end
  end
end

Server-side rendering of Web Component shells requires careful consideration of the component lifecycle. The browser does not execute Web Component constructors for HTML elements parsed from server-rendered markup until after the JavaScript definitions load. This creates a flash of unstyled content. Solutions include rendering complete fallback HTML inside custom element tags, using CSS to hide undefined elements, or deferring component rendering until definitions load. The :defined CSS pseudo-class selects only defined custom elements, enabling progressive enhancement styles.

# Rails controller preparing data for Web Components
class DashboardController < ApplicationController
  def show
    @widgets_data = current_user.widgets.map do |widget|
      {
        id: widget.id,
        type: widget.widget_type,
        config: widget.configuration,
        last_updated: widget.updated_at.iso8601
      }
    end
    
    # Serialize for inline script or data attribute
    @widgets_json = @widgets_data.to_json
  end
end

# In view: passing data to Web Components
<dashboard-grid>
  <% @widgets_data.each do |widget| %>
    <%= tag.send('dashboard-widget',
                 'widget-id': widget[:id],
                 'widget-type': widget[:type],
                 data: { config: widget[:config].to_json }) %>
  <% end %>
</dashboard-grid>

<script type="application/json" id="widgets-data">
  <%= raw @widgets_json %>
</script>

Asset management for Web Components requires organizing component files and managing dependencies. Each component typically consists of a JavaScript class definition, potentially importing shared utilities or styles. Import maps or bundler configurations resolve component dependencies. Rails applications can structure components in app/javascript/components/ with a manifest file importing all definitions. The manifest ensures all components register before the application renders.

# Rake task to generate Web Component boilerplate
namespace :components do
  desc "Generate a new Web Component"
  task :generate, [:name] => :environment do |t, args|
    name = args[:name]
    class_name = name.split('-').map(&:capitalize).join
    
    component_dir = Rails.root.join('app', 'javascript', 'components')
    component_file = component_dir.join("#{name}.js")
    
    template = <<~JAVASCRIPT
      class #{class_name} extends HTMLElement {
        constructor() {
          super();
          this.attachShadow({ mode: 'open' });
        }
        
        connectedCallback() {
          this.render();
        }
        
        render() {
          this.shadowRoot.innerHTML = `
            <style>
              :host {
                display: block;
              }
            </style>
            <div class="#{name}">
              <!-- Component content -->
            </div>
          `;
        }
      }
      
      customElements.define('#{name}', #{class_name});
      export default #{class_name};
    JAVASCRIPT
    
    File.write(component_file, template)
    puts "Generated component: #{component_file}"
  end
end

Practical Examples

Building a data table component demonstrates Web Components' capabilities for creating interactive, reusable UI elements. The component accepts data via attributes or properties, renders rows dynamically, and handles user interactions entirely within its shadow DOM. This example shows attribute observation, event handling, and dynamic content generation.

class DataTable extends HTMLElement {
  static get observedAttributes() {
    return ['columns', 'sortable'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._data = [];
    this._sortColumn = null;
    this._sortDirection = 'asc';
  }
  
  set data(value) {
    this._data = Array.isArray(value) ? value : [];
    this.render();
  }
  
  get data() {
    return this._data;
  }
  
  connectedCallback() {
    this.render();
    this.attachEventListeners();
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'columns' && oldValue !== newValue) {
      this.render();
    }
  }
  
  render() {
    const columns = this.getColumns();
    const sortable = this.hasAttribute('sortable');
    
    const sortedData = this.getSortedData();
    
    this.shadowRoot.innerHTML = `
      <style>
        table {
          width: 100%;
          border-collapse: collapse;
          font-family: system-ui, -apple-system, sans-serif;
        }
        
        th, td {
          padding: 12px;
          text-align: left;
          border-bottom: 1px solid #e0e0e0;
        }
        
        th {
          background: #f5f5f5;
          font-weight: 600;
          cursor: ${sortable ? 'pointer' : 'default'};
          user-select: none;
        }
        
        th:hover {
          background: ${sortable ? '#eeeeee' : '#f5f5f5'};
        }
        
        th.sorted::after {
          content: '${this._sortDirection === 'asc' ? '' : ''}';
          margin-left: 8px;
        }
        
        tr:hover {
          background: #fafafa;
        }
      </style>
      <table>
        <thead>
          <tr>
            ${columns.map(col => `
              <th data-column="${col.key}" class="${this._sortColumn === col.key ? 'sorted' : ''}">
                ${col.label}
              </th>
            `).join('')}
          </tr>
        </thead>
        <tbody>
          ${sortedData.map(row => `
            <tr>
              ${columns.map(col => `<td>${this.formatCell(row[col.key], col)}</td>`).join('')}
            </tr>
          `).join('')}
        </tbody>
      </table>
    `;
  }
  
  getColumns() {
    const columnsAttr = this.getAttribute('columns');
    if (columnsAttr) {
      try {
        return JSON.parse(columnsAttr);
      } catch (e) {
        console.error('Invalid columns JSON:', e);
      }
    }
    
    if (this._data.length > 0) {
      return Object.keys(this._data[0]).map(key => ({
        key: key,
        label: key.charAt(0).toUpperCase() + key.slice(1)
      }));
    }
    
    return [];
  }
  
  getSortedData() {
    if (!this._sortColumn) return this._data;
    
    return [...this._data].sort((a, b) => {
      const aVal = a[this._sortColumn];
      const bVal = b[this._sortColumn];
      
      if (aVal === bVal) return 0;
      
      const comparison = aVal < bVal ? -1 : 1;
      return this._sortDirection === 'asc' ? comparison : -comparison;
    });
  }
  
  formatCell(value, column) {
    if (value === null || value === undefined) return '';
    if (column.format === 'date') {
      return new Date(value).toLocaleDateString();
    }
    if (column.format === 'currency') {
      return `$${Number(value).toFixed(2)}`;
    }
    return String(value);
  }
  
  attachEventListeners() {
    if (!this.hasAttribute('sortable')) return;
    
    const thead = this.shadowRoot.querySelector('thead');
    thead.addEventListener('click', (e) => {
      const th = e.target.closest('th');
      if (!th) return;
      
      const column = th.dataset.column;
      if (this._sortColumn === column) {
        this._sortDirection = this._sortDirection === 'asc' ? 'desc' : 'asc';
      } else {
        this._sortColumn = column;
        this._sortDirection = 'asc';
      }
      
      this.render();
      this.attachEventListeners();
      
      this.dispatchEvent(new CustomEvent('sort', {
        detail: { column, direction: this._sortDirection }
      }));
    });
  }
}

customElements.define('data-table', DataTable);

// Usage:
const table = document.createElement('data-table');
table.setAttribute('sortable', '');
table.setAttribute('columns', JSON.stringify([
  { key: 'name', label: 'Name' },
  { key: 'email', label: 'Email' },
  { key: 'created', label: 'Created', format: 'date' }
]));
table.data = [
  { name: 'Alice', email: 'alice@example.com', created: '2024-01-15' },
  { name: 'Bob', email: 'bob@example.com', created: '2024-02-20' }
];
document.body.appendChild(table);

Creating a modal dialog component showcases slot usage, focus management, and accessibility patterns. The component handles keyboard events, manages focus trapping, and provides proper ARIA attributes for screen readers. This example demonstrates how Web Components can encapsulate complex interactive patterns.

class ModalDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._isOpen = false;
  }
  
  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          z-index: 1000;
        }
        
        :host([open]) {
          display: flex;
          align-items: center;
          justify-content: center;
        }
        
        .backdrop {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background: rgba(0, 0, 0, 0.5);
          animation: fadeIn 0.2s ease-out;
        }
        
        .dialog {
          position: relative;
          background: white;
          border-radius: 8px;
          box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
          max-width: 500px;
          max-height: 90vh;
          overflow: auto;
          animation: slideUp 0.3s ease-out;
        }
        
        .header {
          display: flex;
          align-items: center;
          justify-content: space-between;
          padding: 20px 24px;
          border-bottom: 1px solid #e0e0e0;
        }
        
        .title {
          margin: 0;
          font-size: 20px;
          font-weight: 600;
        }
        
        .close-button {
          background: none;
          border: none;
          font-size: 24px;
          cursor: pointer;
          padding: 0;
          width: 32px;
          height: 32px;
          display: flex;
          align-items: center;
          justify-content: center;
          border-radius: 4px;
        }
        
        .close-button:hover {
          background: #f0f0f0;
        }
        
        .content {
          padding: 24px;
        }
        
        @keyframes fadeIn {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        
        @keyframes slideUp {
          from {
            opacity: 0;
            transform: translateY(20px);
          }
          to {
            opacity: 1;
            transform: translateY(0);
          }
        }
      </style>
      
      <div class="backdrop" part="backdrop"></div>
      <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
        <div class="header" part="header">
          <h2 class="title" id="dialog-title">
            <slot name="title">Dialog</slot>
          </h2>
          <button class="close-button" aria-label="Close dialog">×</button>
        </div>
        <div class="content" part="content">
          <slot></slot>
        </div>
      </div>
    `;
  }
  
  setupEventListeners() {
    const backdrop = this.shadowRoot.querySelector('.backdrop');
    const closeButton = this.shadowRoot.querySelector('.close-button');
    
    backdrop.addEventListener('click', () => this.close());
    closeButton.addEventListener('click', () => this.close());
    
    this.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && this._isOpen) {
        this.close();
      }
      if (e.key === 'Tab' && this._isOpen) {
        this.handleTabKey(e);
      }
    });
  }
  
  open() {
    this._isOpen = true;
    this.setAttribute('open', '');
    this._previousActiveElement = document.activeElement;
    
    requestAnimationFrame(() => {
      const closeButton = this.shadowRoot.querySelector('.close-button');
      closeButton.focus();
    });
    
    document.body.style.overflow = 'hidden';
    this.dispatchEvent(new CustomEvent('open'));
  }
  
  close() {
    this._isOpen = false;
    this.removeAttribute('open');
    
    if (this._previousActiveElement) {
      this._previousActiveElement.focus();
    }
    
    document.body.style.overflow = '';
    this.dispatchEvent(new CustomEvent('close'));
  }
  
  handleTabKey(e) {
    const focusableElements = this.shadowRoot.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    if (e.shiftKey && document.activeElement === firstElement) {
      e.preventDefault();
      lastElement.focus();
    } else if (!e.shiftKey && document.activeElement === lastElement) {
      e.preventDefault();
      firstElement.focus();
    }
  }
}

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

// Usage:
const modal = document.querySelector('modal-dialog');
modal.addEventListener('close', () => {
  console.log('Modal closed');
});
openButton.addEventListener('click', () => modal.open());

A form field component with validation demonstrates property/attribute synchronization and form integration. The component participates in form submission, reports validity, and provides custom validation messages. This pattern applies to any custom form control.

class EmailField extends HTMLElement {
  static formAssociated = true;
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._internals = this.attachInternals();
    this._value = '';
  }
  
  connectedCallback() {
    this.render();
    this.setupValidation();
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
        }
        
        .field {
          display: flex;
          flex-direction: column;
          gap: 8px;
        }
        
        label {
          font-weight: 500;
          font-size: 14px;
        }
        
        input {
          padding: 10px 12px;
          border: 1px solid #d0d0d0;
          border-radius: 4px;
          font-size: 14px;
          font-family: inherit;
        }
        
        input:focus {
          outline: none;
          border-color: #4f46e5;
          box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
        }
        
        input:invalid {
          border-color: #dc2626;
        }
        
        .error {
          color: #dc2626;
          font-size: 13px;
        }
      </style>
      
      <div class="field">
        <label for="input">
          ${this.getAttribute('label') || 'Email'}
        </label>
        <input 
          type="email" 
          id="input"
          placeholder="${this.getAttribute('placeholder') || ''}"
          required="${this.hasAttribute('required')}"
        />
        <div class="error" role="alert"></div>
      </div>
    `;
  }
  
  setupValidation() {
    const input = this.shadowRoot.querySelector('input');
    const errorDiv = this.shadowRoot.querySelector('.error');
    
    input.addEventListener('input', () => {
      this._value = input.value;
      this.validate();
    });
    
    input.addEventListener('blur', () => {
      this.validate();
      if (!input.validity.valid) {
        errorDiv.textContent = input.validationMessage;
      }
    });
  }
  
  validate() {
    const input = this.shadowRoot.querySelector('input');
    const errorDiv = this.shadowRoot.querySelector('.error');
    
    if (input.validity.valid) {
      this._internals.setValidity({});
      this._internals.setFormValue(this._value);
      errorDiv.textContent = '';
      return true;
    } else {
      this._internals.setValidity(
        { customError: true },
        input.validationMessage,
        input
      );
      errorDiv.textContent = input.validationMessage;
      return false;
    }
  }
  
  get value() {
    return this._value;
  }
  
  set value(val) {
    this._value = val;
    const input = this.shadowRoot.querySelector('input');
    if (input) {
      input.value = val;
      this.validate();
    }
  }
  
  get form() {
    return this._internals.form;
  }
  
  get validity() {
    return this._internals.validity;
  }
  
  get validationMessage() {
    return this._internals.validationMessage;
  }
  
  checkValidity() {
    return this._internals.checkValidity();
  }
  
  reportValidity() {
    return this._internals.reportValidity();
  }
}

customElements.define('email-field', EmailField);

Design Considerations

Web Components suit applications requiring framework-agnostic reusability, strong encapsulation, or integration across different technology stacks. The specification provides native browser support without external dependencies, eliminating the need for build tools or runtime libraries. This architectural decision favors simplicity and portability over the rich ecosystems surrounding frameworks like React or Vue.

The encapsulation guarantee of Shadow DOM prevents style collisions that plague traditional component approaches. Styles defined within a shadow root cannot leak out, and external styles cannot leak in except through CSS custom properties. This isolation enables safe component composition where multiple components coexist without CSS conflicts. Teams building component libraries for consumption across multiple applications benefit from this guarantee. Global style resets or third-party stylesheets cannot accidentally break component appearance.

Performance characteristics differ from virtual DOM frameworks. Web Components manipulate the actual DOM directly rather than maintaining virtual representations. This approach incurs lower memory overhead but requires more careful DOM manipulation to avoid layout thrashing. Components that update frequently benefit from batching DOM changes within requestAnimationFrame callbacks. The Shadow DOM boundary adds minimal rendering overhead in modern browsers, but large numbers of shadow roots can impact initial render performance.

Framework integration determines Web Component viability in existing applications. Modern frameworks handle Web Components with varying support levels. React requires forwarding refs and manually triggering updates when component properties change. Vue and Angular provide better integration through custom elements directives and schemas. Applications already deeply invested in framework-specific patterns may find Web Components awkward to adopt incrementally. Pure Web Component applications avoid framework dependency but sacrifice the rich tooling and patterns frameworks provide.

// Design decision: Autonomous vs Customized Built-in Element
// Autonomous: Creates entirely new element
class FancyButton extends HTMLElement {
  // Full control over element behavior and styling
  // Cannot be progressively enhanced from standard button
  // Requires explicit instantiation
}
customElements.define('fancy-button', FancyButton);
// Usage: <fancy-button>Click me</fancy-button>

// Customized Built-in: Extends existing HTML element
class FancyButton extends HTMLButtonElement {
  // Inherits button semantics and accessibility
  // Works as fallback in unsupporting browsers
  // Progressive enhancement from standard button
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// Usage: <button is="fancy-button">Click me</button>

Browser support considerations affect deployment decisions. All evergreen browsers support Web Components specifications, but Safari shipped features later than Chrome and Firefox. Older browsers require polyfills that add page weight and initialization time. The Custom Elements polyfill alone exceeds 30KB gzipped. Applications targeting legacy browsers may find polyfill cost prohibitive. Progressive enhancement strategies render usable HTML that Web Components enhance when available.

State management patterns differ from framework conventions. Web Components lack built-in state management, requiring developers to choose approaches. Simple components store state in private properties, updating DOM in response to property changes. Complex applications need state management solutions. Approaches range from custom event-based communication to integrating libraries like Redux or MobX. The lack of standardized patterns increases architecture complexity for large applications.

Build tool requirements depend on component complexity and browser support targets. Simple components using modern JavaScript need no build step - browsers import them directly. Complex components may require TypeScript compilation, SASS processing, or bundling for code splitting. The JavaScript module system handles most dependency management without bundlers. Applications supporting older browsers require transpilation and polyfills. Build pipelines add complexity but unlock optimizations like minification and tree shaking.

Common Patterns

Attribute reflection synchronizes element attributes with internal properties, maintaining consistency between HTML and JavaScript state. Reflected attributes update automatically when properties change, enabling CSS attribute selectors and declarative configuration. The pattern requires getter and setter methods that read from and write to attributes.

class ProgressBar extends HTMLElement {
  static get observedAttributes() {
    return ['value', 'max'];
  }
  
  get value() {
    return Number(this.getAttribute('value')) || 0;
  }
  
  set value(val) {
    this.setAttribute('value', val);
  }
  
  get max() {
    return Number(this.getAttribute('max')) || 100;
  }
  
  set max(val) {
    this.setAttribute('max', val);
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }
  
  render() {
    const percentage = (this.value / this.max) * 100;
    this.shadowRoot.innerHTML = `
      <style>
        .bar { width: 100%; height: 20px; background: #e0e0e0; }
        .fill { height: 100%; background: #4f46e5; transition: width 0.3s; }
      </style>
      <div class="bar">
        <div class="fill" style="width: ${percentage}%"></div>
      </div>
    `;
  }
}

Event delegation centralizes event handling through a single listener on shadow roots rather than individual listeners per element. This pattern reduces memory overhead and handles dynamically created elements automatically. The listener inspects event.target to determine which element triggered the event.

Template-based rendering separates markup from logic by declaring HTML templates that clone repeatedly. The pattern improves performance when rendering similar structures multiple times, as cloning template content avoids re-parsing HTML strings. Templates remain inert until cloned, preventing accidental script execution or resource loading.

class ItemList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // Define template once
    this.template = document.createElement('template');
    this.template.innerHTML = `
      <style>
        .item {
          padding: 12px;
          border-bottom: 1px solid #e0e0e0;
        }
        .item:hover {
          background: #f5f5f5;
        }
      </style>
      <div class="item">
        <span class="name"></span>
        <button class="delete">×</button>
      </div>
    `;
  }
  
  addItem(name) {
    const clone = this.template.content.cloneNode(true);
    clone.querySelector('.name').textContent = name;
    clone.querySelector('.delete').addEventListener('click', (e) => {
      e.target.closest('.item').remove();
    });
    this.shadowRoot.appendChild(clone);
  }
}

Container/Presentational separation divides components into smart containers managing state and logic, and presentational components handling display. Container components fetch data, manage state, and coordinate interactions. Presentational components receive data through properties and communicate through events. This separation enhances reusability and testability.

CSS custom properties create theming systems that penetrate Shadow DOM boundaries. Properties defined on host elements inherit into shadow roots, enabling style customization without breaking encapsulation. Components expose custom properties for configurable aspects like colors, spacing, or typography.

class ThemedCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          background: var(--card-background, white);
          border: 1px solid var(--card-border, #e0e0e0);
          border-radius: var(--card-radius, 8px);
          padding: var(--card-padding, 16px);
          color: var(--card-text, #000000);
        }
      </style>
      <slot></slot>
    `;
  }
}

// Usage with custom properties:
// <themed-card style="--card-background: #f0f0f0; --card-radius: 4px;">

Part-based styling exposes internal shadow DOM elements for external styling via the part attribute. Elements marked with part attributes become targetable through ::part() pseudo-elements in parent document styles. This mechanism provides controlled style extension without breaking shadow DOM encapsulation.

Lazy initialization defers expensive operations until components become visible or interactive. The pattern improves initial page load performance by avoiding unnecessary work. Intersection Observer detects when components enter the viewport, triggering full initialization. Idle callbacks schedule non-critical work during browser idle periods.

class LazyImage extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        img { width: 100%; display: block; }
        .placeholder { background: #f0f0f0; min-height: 200px; }
      </style>
      <div class="placeholder">Loading...</div>
    `;
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.loadImage();
          observer.disconnect();
        }
      });
    });
    
    observer.observe(this);
  }
  
  loadImage() {
    const src = this.getAttribute('src');
    this.shadowRoot.innerHTML = `
      <style>img { width: 100%; display: block; }</style>
      <img src="${src}" alt="${this.getAttribute('alt') || ''}">
    `;
  }
}

Security Implications

Web Components introduce security considerations around DOM injection, style isolation bypass, and cross-site scripting vectors. Shadow DOM boundaries provide style encapsulation but do not create security boundaries. Malicious code executing within a shadow root accesses the entire document. Components must sanitize untrusted input and validate data from attributes or properties.

XSS vulnerabilities arise when components inject user-provided content into shadow DOM without sanitization. Setting innerHTML with unsanitized data executes embedded scripts. The same vulnerability exists in light DOM manipulation. Components should use textContent for text insertion or employ sanitization libraries like DOMPurify for HTML. Attribute values require HTML entity encoding before insertion into templates.

// VULNERABLE: XSS through innerHTML
class VulnerableComponent extends HTMLElement {
  connectedCallback() {
    const userInput = this.getAttribute('content');
    // DANGEROUS: executes scripts in userInput
    this.shadowRoot.innerHTML = `<div>${userInput}</div>`;
  }
}

// SAFE: Text content insertion
class SafeComponent extends HTMLElement {
  connectedCallback() {
    const userInput = this.getAttribute('content');
    const div = document.createElement('div');
    div.textContent = userInput; // Safe: treated as text
    this.shadowRoot.appendChild(div);
  }
}

// SAFE: Sanitized HTML insertion
import DOMPurify from 'dompurify';

class SanitizedComponent extends HTMLElement {
  connectedCallback() {
    const userInput = this.getAttribute('content');
    const clean = DOMPurify.sanitize(userInput);
    this.shadowRoot.innerHTML = clean;
  }
}

Trusted Types policies enforce safer string handling by requiring explicit policy application before dangerous DOM operations. Components supporting Trusted Types validate all HTML assignments, preventing accidental XSS. Policies declare allowed operations and sanitization rules. Applications can enforce Trusted Types through Content Security Policy headers, blocking any unsafe assignments.

// Trusted Types integration
class TrustedComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // Create Trusted Types policy
    if (window.trustedTypes) {
      this.policy = trustedTypes.createPolicy('component-policy', {
        createHTML: (input) => {
          // Sanitize input before creating trusted HTML
          return DOMPurify.sanitize(input);
        }
      });
    }
  }
  
  setContent(html) {
    if (this.policy) {
      this.shadowRoot.innerHTML = this.policy.createHTML(html);
    } else {
      // Fallback for browsers without Trusted Types
      this.shadowRoot.innerHTML = DOMPurify.sanitize(html);
    }
  }
}

Slot injection attacks occur when malicious content in light DOM slots manipulates component behavior. Components must validate slotted content and avoid executing scripts or styles from untrusted sources. The slotchange event enables inspection of slot content before use. Components should treat slotted content as potentially hostile and restrict capabilities accordingly.

CSS injection through custom properties allows attackers to exfiltrate data or manipulate appearance. Custom properties accept arbitrary values including URLs that trigger network requests. Components should validate custom property values or avoid using them in contexts like url() functions. Content Security Policy headers restrict resource loading, mitigating CSS injection risks.

Cross-origin considerations apply when components load external resources. Shadow DOM does not isolate network requests - components inherit the document's origin. Loading scripts, images, or stylesheets from untrusted origins risks code injection. Subresource Integrity attributes verify external resource integrity. Content Security Policy directives restrict allowed resource sources.

// Secure external resource loading
class SecureComponent extends HTMLElement {
  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        /* Avoid user-controlled URLs in CSS */
        .safe { background: var(--user-color, #fff); }
        /* NEVER: background-image: url(var(--user-image)); */
      </style>
      
      <!-- External resource with SRI -->
      <link 
        rel="stylesheet" 
        href="https://cdn.example.com/style.css"
        integrity="sha384-hash"
        crossorigin="anonymous"
      />
    `;
  }
}

Form-associated custom elements participating in form submission must prevent CSRF attacks. Components should include CSRF tokens in submitted data or rely on same-site cookie policies. The ElementInternals API provides setFormValue() for safe form value setting, but components must validate all data before submission.

Accessibility security involves preventing malicious ARIA attributes from misleading assistive technologies. Components should validate ARIA attribute values and avoid dynamically generating attributes from untrusted input. Screen reader announcements derived from user content require sanitization to prevent social engineering attacks.

Reference

Custom Elements API

Method/Property Description Usage
customElements.define() Registers custom element definition Define new custom element with tag name and class
customElements.get() Retrieves constructor for custom element Get constructor for previously defined element
customElements.whenDefined() Returns promise resolving when element defined Wait for element definition before using
customElements.upgrade() Upgrades element to custom element Force upgrade of element to custom element class
constructor() Initializes element instance Set up shadow root and initial state
connectedCallback() Called when element added to DOM Initialize rendering and event listeners
disconnectedCallback() Called when element removed from DOM Clean up resources and event listeners
attributeChangedCallback() Called when observed attribute changes React to attribute modifications
adoptedCallback() Called when element moved to new document Handle document context changes
observedAttributes Static getter listing observed attributes Declare which attributes trigger callback

Shadow DOM API

Method/Property Description Usage
attachShadow() Creates shadow root Attach shadow DOM to element with mode option
shadowRoot References element's shadow root Access shadow DOM for manipulation
mode: open Shadow root accessible via shadowRoot property Allow external JavaScript to access shadow content
mode: closed Shadow root hidden from external access Prevent external JavaScript from accessing shadow
delegatesFocus Focus delegation to shadow content Automatically focus first focusable element
slot Distribute light DOM content Assign content to named or default slots
slotchange event Fires when slot content changes React to slot content modifications
assignedNodes() Returns nodes assigned to slot Access distributed content in slots
assignedElements() Returns elements assigned to slot Access distributed elements in slots

CSS Shadow DOM Selectors

Selector Description Example
:host Selects shadow host Style the custom element itself
:host() Selects host matching selector Conditional host styling
:host-context() Selects host based on ancestor Style based on containing context
::slotted() Selects slotted elements Style light DOM content in slots
::part() Selects exported shadow parts Style specific shadow DOM elements

Lifecycle Callback Execution Order

Stage Callbacks Fired Timing
Element Creation constructor() Synchronous during element instantiation
Attribute Setting attributeChangedCallback() After construction, before connection
DOM Insertion connectedCallback() After element added to document
Attribute Change attributeChangedCallback() When observed attribute modified
DOM Removal disconnectedCallback() When element removed from document
Document Move adoptedCallback() When element moved between documents

Form-Associated Custom Elements

Method/Property Description Usage
formAssociated Static boolean enabling form association Set to true to associate with forms
attachInternals() Creates ElementInternals instance Access form control functionality
setFormValue() Sets element's form submission value Provide value for form submission
setValidity() Sets element validation state Define validation rules and messages
checkValidity() Checks element validity Validate without showing UI
reportValidity() Validates and shows UI Validate with user feedback
form References associated form element Access containing form
validity Returns ValidityState object Check specific validation failures
validationMessage Returns validation message Get user-facing error message

Best Practices Checklist

Practice Description Implementation
Use semantic HTML Extend existing elements when appropriate Prefer customized built-in elements for form controls
Implement accessibility Include ARIA attributes and keyboard support Add role, aria-label, and keyboard event handlers
Handle errors gracefully Validate inputs and provide fallbacks Check attribute values and handle invalid data
Optimize rendering Batch DOM updates and use templates Use requestAnimationFrame and template cloning
Clean up resources Remove listeners in disconnectedCallback Clear timers and remove event listeners
Document public API Specify accepted attributes and events Provide JSDoc comments and usage examples
Test across browsers Verify behavior in all target browsers Test shadow DOM, slots, and lifecycle hooks
Consider progressive enhancement Provide fallback content Include usable HTML inside custom elements