Contents

Web Components: Build Framework-Agnostic UI Elements

Web Components are native browser APIs - Custom Elements, Shadow DOM, and HTML Templates - that let you create reusable, encapsulated UI elements like <modal-dialog> or <accordion-panel> that work in React, Vue, Svelte, Angular, or plain HTML without build tools or framework dependencies. With 98% browser support across all modern browsers in 2026, they are the most portable component format available: write it once, ship it anywhere.

The Three APIs That Make Up Web Components

Web Components is an umbrella term for three distinct browser APIs that work together. You can use each independently - Custom Elements without Shadow DOM, Shadow DOM without Templates - but the combination is where they become genuinely useful.

The Custom Elements API (customElements.define()) lets you register a new HTML tag name backed by a JavaScript class. The tag name must contain a hyphen (e.g., <user-card>, <modal-dialog>), which prevents collisions with existing or future HTML elements. Your class extends HTMLElement, and the browser calls lifecycle callbacks as your element moves through the DOM:

  • connectedCallback() - fires when the element is inserted into the DOM; good for setting up event listeners or fetching data
  • disconnectedCallback() - fires when removed; clean up listeners and cancel pending requests here
  • attributeChangedCallback(name, oldVal, newVal) - fires when an observed attribute changes; declare which attributes to watch with static get observedAttributes()

Shadow DOM (this.attachShadow({ mode: 'open' })) creates an encapsulated DOM subtree attached to your element. CSS inside the shadow root does not leak out to the page, and external page CSS does not leak in (with a few exceptions - inherited properties like font-family do propagate). This solves the global CSS collision problem that haunts large applications and component libraries. Use Shadow DOM for components that need style isolation: design system primitives, third-party embeddable widgets, components distributed across teams. Skip it for internal app components where you want global stylesheets to apply naturally.

Image: MDN Web Docs , CC-BY-SA 2.5

HTML Templates (<template> and <slot>) complete the set. The <template> element holds inert HTML that is not rendered or executed until you clone it into the DOM - an efficient way to define the structure of your component once and stamp it out many times. <slot> elements inside shadow DOM are named insertion points where consumers inject their own content (called light DOM), similar to Vue slots or React’s children prop.

One important compatibility note: there are two kinds of custom elements. Autonomous elements (extends HTMLElement) create entirely new tags and have universal support. Customized built-in elements (extends HTMLButtonElement with is="fancy-button") extend existing HTML elements to inherit their native behavior and accessibility - but Safari has stated they will never implement this. Stick to autonomous elements for cross-browser compatibility.

Building a Practical Modal Dialog Component

A modal dialog demonstrates all three APIs working together. The structure is a <modal-dialog> custom element with a Shadow DOM containing a backdrop div, a dialog container, a close button, and three <slot> elements for header, body, and footer:

<modal-dialog>
  <span slot="header">Confirm Action</span>
  <p slot="body">Are you sure you want to delete this item?</p>
  <div slot="footer">
    <button id="cancel">Cancel</button>
    <button id="confirm">Delete</button>
  </div>
</modal-dialog>

The implementation uses the native <dialog> element inside the shadow root rather than building backdrop and focus-trap logic from scratch. Calling dialog.showModal() gives you correct stacking context, native focus trapping, Escape key handling, and the ::backdrop pseudo-element for the overlay without any extra code. Rolling your own modal with position: fixed means reimplementing all of this.

For the attribute-driven API, you observe an open boolean attribute:

class ModalDialog extends HTMLElement {
  static get observedAttributes() { return ['open']; }

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        dialog { border: none; border-radius: 8px; padding: 0; max-width: 500px; }
        dialog::backdrop { background: rgba(0,0,0,0.5); }
        .dialog-inner { padding: 1.5rem; }
      </style>
      <dialog>
        <div class="dialog-inner">
          <slot name="header"></slot>
          <slot name="body"></slot>
          <slot name="footer"></slot>
        </div>
      </dialog>
    `;
  }

  attributeChangedCallback(name, oldVal, newVal) {
    const dialog = this.shadowRoot?.querySelector('dialog');
    if (!dialog) return;
    if (name === 'open') {
      newVal !== null ? dialog.showModal() : dialog.close();
    }
  }
}

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

For custom events crossing the shadow boundary, dispatch with composed: true and bubbles: true. Without composed: true, events stop at the shadow root and cannot be caught by listeners in the outer document:

this.dispatchEvent(new CustomEvent('modal-close', {
  bubbles: true,
  composed: true
}));

Building a Reusable Accordion Component

An accordion demonstrates dynamic multi-instance components and parent-child coordination. The architecture uses two elements: <accordion-group> as the parent that manages which panels are open, and <accordion-panel> as the repeatable child.

Each <accordion-panel> uses a <slot name="header"> for the clickable trigger and a default slot for the panel content. The content wrapper animates max-height from 0 to scrollHeight using CSS transitions for smooth expansion - this works because scrollHeight gives you the natural content height, and transitioning from 0 to that value creates a genuine expand effect rather than an abrupt jump.

class AccordionPanel extends HTMLElement {
  static get observedAttributes() { return ['expanded']; }

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .content-wrapper { overflow: hidden; max-height: 0; transition: max-height var(--accordion-transition-duration, 0.3s) ease; }
        :host([expanded]) .content-wrapper { max-height: var(--panel-height, 500px); }
        button { width: 100%; text-align: left; background: var(--accordion-header-bg, #f5f5f5);
          color: var(--accordion-header-color, #333); border: none; padding: 1rem; cursor: pointer;
          border-radius: var(--accordion-border-radius, 4px); }
      </style>
      <button aria-expanded="false" part="header">
        <slot name="header"></slot>
      </button>
      <div class="content-wrapper" role="region">
        <slot></slot>
      </div>
    `;
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('panel-toggle', { bubbles: true, composed: true }));
    });
  }
}

The <accordion-group> listens for panel-toggle events from child panels. In single-expand mode, when one panel opens, it removes the expanded attribute from all other panels. The pattern here is events bubble up, attributes flow down - the same shape as React or Vue’s unidirectional data flow, but implemented entirely with DOM APIs and no framework involvement.

For accessibility, the header button gets aria-expanded (updated on state change) and aria-controls pointing to the panel content’s id. The content region gets role="region" and aria-labelledby pointing back to the header. Keyboard navigation should support Enter/Space to toggle and arrow keys to move between panels.

CSS custom properties are the main theming mechanism - they pierce the shadow DOM boundary, letting consumers apply a theme without reaching into the shadow root:

accordion-group {
  --accordion-header-bg: #2563eb;
  --accordion-header-color: #fff;
  --accordion-border-radius: 8px;
}

For exposing specific shadow DOM internals to external CSS without opening the whole shadow root, use the part attribute and the ::part() pseudo-element:

<!-- inside shadow DOM template -->
<button part="header">...</button>
/* outside, in the page stylesheet */
accordion-panel::part(header) {
  font-weight: 700;
  letter-spacing: 0.05em;
}

For nested components, exportparts lets you re-export part names up the tree: exportparts="header: accordion-header" makes the header part addressable from any ancestor.

Distribution, Testing, and Production Patterns

Publishing a Web Component as an npm package is simple: export it as an ES module, and the customElements.define() call runs as a side effect of the import. Consumers install and import with:

import 'your-package/modal-dialog.js';
// <modal-dialog> is now available everywhere on the page

Include TypeScript declaration files (.d.ts) for editor autocompletion. If you want to expose your component’s properties and methods to TypeScript consumers, extend the HTMLElementTagNameMap interface with your element’s type.

For CDN distribution - zero install, no package manager - host on esm.sh or jsDelivr :

<script type="module" src="https://esm.sh/your-package/modal-dialog.js"></script>

If your component works with a single script tag and no build pipeline, it is genuinely framework-agnostic.

Testing is handled well by Open Web Components (@open-wc/testing), which provides fixture() for rendering components in a real browser harness, shadow DOM assertion helpers, and oneEvent() for testing custom event dispatch. Run tests with Web Test Runner (@web/test-runner) against actual Chrome/Firefox/Safari rather than jsdom, since Shadow DOM behavior differs from Node.js simulations.

For server-side rendering, standard Web Components produce empty custom element tags until JavaScript hydrates them. Declarative Shadow DOM solves this by embedding the shadow tree directly in the HTML response:

<modal-dialog>
  <template shadowrootmode="open">
    <style>dialog { ... }</style>
    <dialog>...</dialog>
  </template>
</modal-dialog>

Declarative Shadow DOM is now supported in Chrome 111+, Edge 111+, Safari 16.4+, and Firefox 123+. It is on track to reach Baseline Widely Available status in mid-2026, making SSR a practical option for production Web Components.

To avoid FOUC (Flash of Unstyled Content) while components register, add this to your page CSS:

:not(:defined) {
  opacity: 0;
  transition: opacity 0.2s;
}
:defined {
  opacity: 1;
}

When to Use Lit Instead of Vanilla

Lit - Google’s Web Component library at ~5kb minified and gzipped - adds reactive properties, declarative HTML templates, and automatic re-rendering on state change. Where vanilla Web Components require you to manually diff the DOM or reconstruct innerHTML when data changes, Lit handles efficient updates automatically.

Vanilla Web Components make sense for small, mostly static components where bundle size matters and you want zero dependencies. If your component has limited state after initial render, there is no reason to add a library.

Lit pays off when components have complex reactive state, when you are building a library of dozens of elements, or when you want TypeScript decorators and type-safe templates. The authoring experience is noticeably more productive than managing innerHTML rewrites manually.

Either way, every Lit component is a standard Web Component - switching to Lit does not give up portability or interoperability.

Form-Associated Custom Elements

The ElementInternals API, now supported in Chrome, Edge, Firefox, and Safari 16.4+, lets custom elements participate in native HTML forms - including form submission, validation, and label association. To opt in, declare static formAssociated = true and call this.attachInternals() in the constructor:

class CustomInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  set value(v) {
    this.internals.setFormValue(v);
  }
}

The element then behaves like a native input: it appears in FormData, responds to form reset, and can report validity using internals.setValidity(). This is essential for building custom checkboxes, date pickers, or specialized inputs that work with standard <form> elements without any wrapper components or JavaScript workarounds.

Framework Integration

Using Web Components in major frameworks is straightforward with minor configuration:

FrameworkIntegration
React 19+Works natively - properties and events bind correctly to custom elements
React 18Use string attributes for all data; wrap events in useEffect listeners
Vue 3Add app.config.compilerOptions.isCustomElement = tag => tag.includes('-')
SvelteCustom elements work natively in templates with no configuration
AngularAdd CUSTOM_ELEMENTS_SCHEMA to your NgModule or standalone component

The React 19 improvement is significant - prior versions treated unknown attributes as strings and did not forward rich objects or DOM event listeners correctly to custom elements, requiring awkward ref-based workarounds.

Web Components are the only component model where you write it once and ship it everywhere, with the browser itself as the runtime rather than a per-framework adapter layer.