Contents

Web Components: Build Framework-Agnostic UI Elements

Web Components are native browser APIs: Custom Elements, Shadow DOM, and HTML Templates. They let you build reusable UI parts like <modal-dialog> or <accordion-panel> that work in React, Vue, Svelte, Angular, or plain HTML. No build tools, no framework lock-in. With 98% browser support in 2026, they’re the most portable component format around. Write it once, ship it anywhere.

The Three APIs That Make Up Web Components

Web Components is an umbrella term for three browser APIs that work together. You can use each one on its own. Custom Elements without Shadow DOM, Shadow DOM without Templates. But the combination is where they shine.

The Custom Elements API (customElements.define()) lets you register a new HTML tag backed by a JavaScript class. The tag name must contain a hyphen, like <user-card> or <modal-dialog>. That hyphen rule keeps your tags from clashing with built-in HTML. Your class extends HTMLElement, and the browser calls lifecycle hooks as your element moves through the DOM:

  • connectedCallback() fires when the element joins the DOM. Good spot for setting up event listeners or fetching data.
  • disconnectedCallback() fires when it leaves. Clean up listeners and cancel pending requests here.
  • attributeChangedCallback(name, oldVal, newVal) fires when a watched attribute changes. Declare which attributes to watch with static get observedAttributes().

Shadow DOM (this.attachShadow({ mode: 'open' })) creates a sealed DOM subtree attached to your element. CSS inside the shadow root does not leak out to the page. External page CSS does not leak in either, though a few inherited properties like font-family still pass through.

This fixes the global CSS collision problem that plagues large apps and component libraries. Use Shadow DOM when you need style isolation: design system primitives, embeddable widgets, parts shared across teams. Skip it for internal app components where you want global stylesheets to apply.

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

HTML Templates (<template> and <slot>) round out the set. The <template> element holds inert HTML. Nothing renders or runs until you clone it into the DOM. It’s a tidy way to define your component’s structure once and stamp it out many times. <slot> elements inside shadow DOM are named insertion points where consumers drop in their own content (the “light DOM”). The shape is similar to Vue slots or React’s children prop.

One key compatibility note. There are two kinds of custom elements. Autonomous elements (extends HTMLElement) create brand-new tags and work in every browser. Customized built-in elements (extends HTMLButtonElement with is="fancy-button") extend existing HTML elements to inherit native behavior and a11y. However, Safari has said flat out that it will never ship this. Stick to autonomous elements if you want cross-browser support.

Building a Practical Modal Dialog Component

A modal dialog shows all three APIs working together. The structure is a <modal-dialog> custom element with a Shadow DOM. Inside: 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 code uses the native <dialog> element inside the shadow root. No need to build backdrop and focus-trap logic from scratch. Calling dialog.showModal() gives you a correct stacking context, native focus trapping, Escape key handling, and the ::backdrop pseudo-element for the overlay. All free, all native.

Rolling your own modal with position: fixed means rebuilding all of that. For pure-CSS overlay patterns like tooltips and popovers, the CSS Anchor Positioning API is a nice browser-native partner.

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 that cross the shadow boundary, dispatch with composed: true and bubbles: true. Without composed: true, events stop at the shadow root. Listeners in the outer document never see them.

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

Building a Reusable Accordion Component

An accordion is a good test of dynamic, multi-instance components that talk to each other. Two elements do the work: <accordion-group> is the parent that tracks which panels are open, and <accordion-panel> is 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 with a CSS transition. This works because scrollHeight gives you the real content height. The transition from 0 to that value creates a true expand effect instead of a 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 its child panels. In single-expand mode, when one panel opens, the parent strips the expanded attribute from all the others. The pattern is events bubble up, attributes flow down. Same shape as one-way data flow in React or Vue, but done with pure DOM APIs and no framework in sight.

For a11y, 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 support should cover Enter and Space to toggle, plus arrow keys to move between panels.

CSS custom properties are the main way to theme a component. They cross the shadow DOM boundary, so consumers can apply a theme without poking into the shadow root:

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

To expose specific shadow DOM internals to outside 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 re-exports part names up the tree. Setting exportparts="header: accordion-header" makes the header part reachable from any ancestor.

Distribution, Testing, and Production Patterns

Publishing a Web Component as an npm package is easy. Export it as an ES module. The customElements.define() call runs as a side effect of the import. Consumers install and import like so:

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

Ship TypeScript declaration files (.d.ts) for editor autocomplete. To expose your component’s props and methods to TypeScript users, extend the HTMLElementTagNameMap interface with your element’s type.

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

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

If your component runs from a single script tag with no build step, it’s truly framework-agnostic.

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

For server-side rendering, plain Web Components show up as empty custom element tags until JavaScript hydrates them. Declarative Shadow DOM fixes this. It bakes the shadow tree straight into the HTML response:

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

Declarative Shadow DOM works in Chrome 111+, Edge 111+, Safari 16.4+, and Firefox 123+. It should hit Baseline Widely Available in mid-2026. That makes SSR a real option for production Web Components.

To dodge FOUC (Flash of Unstyled Content) while components register, drop this in your page CSS:

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

When to Use Lit Instead of Vanilla

Lit is Google’s Web Component library, around 5kb minified and gzipped. It adds reactive properties, declarative HTML templates, and auto re-render on state change. With vanilla Web Components, you diff the DOM by hand or rebuild innerHTML when data changes. Lit does the efficient updates for you.

Vanilla Web Components are the right call for small, mostly static parts where bundle size counts and you want zero deps. If your component barely changes state after first render, there’s no reason to pull in a library.

Lit pays off when components have rich reactive state, when you’re building a library of dozens of elements, or when you want TypeScript decorators and type-safe templates. Writing in Lit feels notably faster than hand-rolling innerHTML rewrites.

Either way, every Lit component is a standard Web Component. Picking Lit does not cost you portability or cross-framework support.

Form-Associated Custom Elements

The ElementInternals API works in Chrome, Edge, Firefox, and Safari 16.4+. It lets custom elements take part in native HTML forms. That covers form submission, validation, and label hookup. To opt in, set 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 acts like a native input. It shows up in FormData, responds to form reset, and can report validity through internals.setValidity(). This is the key trick for building custom checkboxes, date pickers, or other inputs that plug into standard <form> elements with no wrapper component or JavaScript hack.

Framework Integration

Using Web Components in the big frameworks is easy with a small config tweak:

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 fix is a big one. Earlier versions treated unknown attributes as strings. They did not forward rich objects or DOM event listeners to custom elements the way you’d want. That meant clunky ref-based workarounds.

Web Components are the only component model where you write it once and ship it everywhere. The browser itself is the runtime , not a per-framework adapter layer.