Web Components Patterns

Production engineering guide for web components patterns covering patterns, implementation strategies, and operational best practices.

Web Components Patterns is a collection of proven, repeatable strategies for building modular, reusable, and self-contained UI elements using native web technologies. It matters when you need to share components across multiple frameworks—React, Vue, Angular, and plain HTML—without incurring framework-specific overhead, and when components must work in isolation, in any hosting environment: from static sites to complex SPAs, from micro-frontends to embedded widgets.

Building a Reusable Web Component

A Web Component is a self-contained unit of UI built with the standards: HTML templates, custom elements, and CSS shadow DOM. The core pattern is to define a <my-button> element that encapsulates markup, styles, and behavior, and then use it anywhere without side effects.

<my-button label="Click me" variant="primary" size="large"></my-button>

To create the component, define a class extending HTMLElement:

class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
          font-family: sans-serif;
        }
        button {
          padding: 0.5em 1em;
          border: none;
          border-radius: 4px;
          background: var(--bg, #007acc);
          color: white;
          font-size: var(--size, 1rem);
          cursor: pointer;
        }
        button:hover {
          background: #005f99;
        }
      </style>
      <button id="btn"></button>
    `;
  }

  connectedCallback() {
    const btn = this.shadowRoot.getElementById('btn');
    btn.textContent = this.getAttribute('label') || 'Button';
    btn.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('click', { bubbles: true }));
    });
  }

  static get observedAttributes() {
    return ['label', 'variant', 'size'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    const btn = this.shadowRoot.getElementById('btn');
    const root = this.shadowRoot.host;

    switch (name) {
      case 'label':
        btn.textContent = newValue;
        break;
      case 'variant':
        root.style.setProperty('--bg', this.variantColor(newValue));
        break;
      case 'size':
        root.style.setProperty('--size', this.sizeValue(newValue));
        break;
    }
  }

  variantColor(variant) {
    const map = {
      primary: '#007acc',
      secondary: '#6c757d',
      success: '#28a745',
      danger: '#dc3545'
    };
    return map[variant] || '#007acc';
  }

  sizeValue(size) {
    const map = {
      small: '0.8rem',
      medium: '1rem',
      large: '1.25rem'
    };
    return map[size] || '1rem';
  }
}

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

The key insight is that shadowRoot provides true encapsulation: styles do not leak in or out, and DOM structure is isolated. The connectedCallback runs when the element is inserted into the DOM, and attributeChangedCallback responds to changes in attributes. Use observedAttributes to declare which attributes trigger updates.

Shadow DOM: The Foundation of Isolation

Shadow DOM is the heart of a Web Component. It creates a boundary between the component’s internal DOM and the document outside. This boundary prevents global CSS from affecting the component and ensures the component’s styles do not bleed into the host.

When a component is rendered inside a <my-card> that has a background-color set to #f0f0f0, the card’s styles do not affect the button’s internal layout unless explicitly configured.

To control how the shadow DOM interacts with the host, use the mode option in attachShadow:

Use open mode when you want developers to style or manipulate the component from outside the component’s code. Use closed when you want to enforce a strict API and hide internals.

The most common failure is forgetting to call render() or update() after setting properties. For example:

const btn = document.querySelector('my-button');
btn.setAttribute('label', 'New Label');
// No automatic update: shadow DOM is static unless explicitly updated

To fix this, define a render() method and call it in attributeChangedCallback and connectedCallback.

render() {
  const btn = this.shadowRoot.getElementById('btn');
  btn.textContent = this.getAttribute('label') || 'Button';
  this.shadowRoot.host.style.setProperty('--bg', this.variantColor(this.getAttribute('variant')));
}

Passing Data via Properties and Events

Web Components communicate via properties and events. Properties are set via attributes or JavaScript, and events are dispatched for user interactions.

To expose a property, use get and set accessors on the class:

get disabled() {
  return this.hasAttribute('disabled');
}

set disabled(value) {
  if (value) {
    this.setAttribute('disabled', '');
  } else {
    this.removeAttribute('disabled');
  }
}

Set properties directly on the element:

const btn = document.querySelector('my-button');
btn.label = 'Submit';
btn.variant = 'success';
btn.disabled = true;

For more complex data, use JSON.parse on attribute values:

get config() {
  return JSON.parse(this.getAttribute('config') || '{}');
}

set config(value) {
  this.setAttribute('config', JSON.stringify(value));
}

Events are dispatched with dispatchEvent(new CustomEvent(...)). Use bubbles: true so the event propagates up the DOM tree.

btn.addEventListener('click', (e) => {
  console.log('Button clicked:', e.detail);
});

The detail property is the payload, and it can contain any structured data.

Handling Lifecycle and State

Web Components evolve over time. They are not static. To manage state, store it in this or use Map or WeakMap for private data.

class MyForm extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._state = new Map();
    this._state.set('submitted', false);
    this._state.set('errors', []);
  }

  get submitted() {
    return this._state.get('submitted');
  }

  set submitted(value) {
    this._state.set('submitted', value);
    this.render();
  }

  render() {
    const form = this.shadowRoot.querySelector('form');
    form.classList.toggle('submitted', this.submitted);
  }

  connectedCallback() {
    this.shadowRoot.querySelector('form').addEventListener('submit', (e) => {
      e.preventDefault();
      this.submitted = true;
      this.dispatchEvent(new CustomEvent('submit', { detail: { data: this.getData() } }));
    });
  }

  getData() {
    return Array.from(this.shadowRoot.querySelectorAll('input'))
      .reduce((acc, el) => {
        acc[el.name] = el.value;
        return acc;
      }, {});
  }
}

The connectedCallback runs once when the element is attached. disconnectedCallback runs when it is removed.

disconnectedCallback() {
  this.shadowRoot.querySelector('form').removeEventListener('submit', this.submitHandler);
  this._state.clear();
}

This pattern prevents memory leaks and ensures clean teardown.

Customizing Appearance with Slots and CSS Variables

To allow rich composition, use <slot> elements inside the shadow DOM to define where content can be inserted.

<!-- Inside shadowRoot -->
<slot name="leading"></slot>
<button>
  <slot>Default Label</slot>
</button>
<slot name="trailing"></slot>

Then use the component like:

<my-button>
  <span slot="leading">▶</span>
  <span>Save Draft</span>
  <span slot="trailing">💾</span>
</my-button>

Use ::slotted() in CSS to style slotted content:

::slotted(span) {
  font-weight: 500;
  margin: 0 0.25em;
}

For global theming, define CSS custom properties (variables) that can be set on the host element:

:host {
  --button-bg: #007acc;
  --button-font-size: 1rem;
  --button-padding: 0.5em 1em;
}

:host([variant="large"]) {
  --button-font-size: 1.25rem;
  --button-padding: 0.75em 1.5em;
}

These variables can be set directly on the element:

document.querySelector('my-button').style.setProperty('--button-bg', '#ff6b35');

Consuming Web Components in Frameworks

Web Components are framework-agnostic. To use a component in React, Vue, or Angular, import the JavaScript and include it in the bundle.

In React:

import { MyButton } from './my-button.js';

function App() {
  return (
    <div>
      <MyButton label="Submit" variant="primary" size="large" />
    </div>
  );
}

In Vue:

<template>
  <div>
    <my-button label="Save" variant="success" size="small" />
  </div>
</template>

<script>
import { MyButton } from './my-button.js';

export default {
  components: { MyButton }
};
</script>

Framework-specific bindings are not required. The component works as a plain <my-button> element.

When embedding in a framework, the component receives attributes and dispatches events, but framework-specific state (like React’s useState) is independent.

Common Pitfalls and Silent Failures

Bundling and Distribution

To distribute a Web Component, bundle it with a tool like esbuild, rollup, or webpack.

esbuild src/my-button.js --bundle --format=esm --outfile=dist/my-button.js

Use package.json to define module entry points:

{
  "name": "my-button",
  "version": "1.0.0",
  "main": "dist/my-button.js",
  "module": "dist/my-button.js",
  "types": "dist/my-button.d.ts",
  "exports": {
    ".": {
      "import": "./dist/my-button.js",
      "require": "./dist/my-button.js"
    }
  }
}

Publish to npm:

npm publish

Consumers install and use:

npm install my-button
import { MyButton } from 'my-button';
customElements.define('my-button', MyButton);

Advanced: Dynamic and Lazy-Loaded Components

For performance, load Web Components on demand.

async function loadComponent(name) {
  const module = await import(`./components/${name}.js`);
  customElements.define(name, module.default);
}

// Use when needed
loadComponent('my-dialog').then(() => {
  const dialog = document.createElement('my-dialog');
  document.body.appendChild(dialog);
});

Or load multiple components in a batch:

const components = ['my-button', 'my-card', 'my-form'];
Promise.all(components.map(loadComponent)).then(() => {
  console.log('All components loaded');
});

Use IntersectionObserver to lazy-load components when they enter the viewport.

This completes the core set of patterns for building robust, reusable, and performant Web Components.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.