Web Components in Practice: Building Reusable UI

·18 min read·Web Developmentintermediate

In a framework-heavy world, Web Components offer a stable, framework-agnostic path to share UI that works across teams and stacks.

A developer workstation showing code on a monitor with Web Components examples rendered in a browser, illustrating reusable UI elements like buttons and cards

I’ve shipped components in React, Vue, Svelte, Angular, and even jQuery codebases. The biggest pain I’ve felt is not building a nice widget once, but making it reliable and reusable in different contexts: a React admin panel, a legacy server-rendered app, a design system doc site, and maybe an embeddable widget for partners. That is the problem Web Components were made to solve, and it’s why they’ve quietly become practical again.

In this post, I’ll share how I approach Web Components in real projects, where they shine, where they don’t, and how to structure them for long-term maintainability. You’ll see full, copyable code for typical patterns, from project setup to async data loading and accessibility, along with honest tradeoffs and a few war stories from production.

Where Web Components fit today

Web Components are a set of native browser standards: Custom Elements, Shadow DOM, and HTML Templates. They let you define reusable UI elements using standard web APIs, which means they work without being tied to a specific framework. You can ship a component as a plain JavaScript module and use it in React, Vue, Angular, or a vanilla HTML page with no build step, provided you’re targeting modern browsers.

In real-world projects, teams adopt Web Components for a few common scenarios:

  • Design systems that need to survive multiple framework migrations.
  • Micro frontends where different teams use different stacks.
  • Embeddable widgets (e.g., chat, payment, analytics badges) that need to be framework-agnostic.
  • Content-heavy sites where you want interactive islands without shipping a heavy framework runtime.

Who uses them today? Design system teams, platform UI teams, and companies with multiple frontend stacks. A practical example you can inspect is Adobe’s Spectrum Web Components, which implement Adobe’s design language in a framework-agnostic way: https://opensource.adobe.com/spectrum-web-components/

Compared to alternatives:

  • Framework components (React, Vue, etc.) give richer devtools, larger ecosystems, and server-side rendering strategies. But they tie you to a runtime and create version lock-in.
  • Web Components are standard, lightweight, and long-lived. The tradeoff is that some framework conveniences (e.g., hooks, fine-grained reactivity, SSR integration) must be solved explicitly.

The baseline support is strong: all major evergreen browsers support Custom Elements and Shadow DOM. If you need to support older browsers like IE11, you’ll need polyfills and a more constrained build setup. As of 2025, most teams can rely on native support without extra polyfills: https://developer.mozilla.org/en-US/docs/Web/Web_Components

Core concepts with practical patterns

The power of Web Components is in how you combine the standards. Here’s how I use them day-to-day.

Custom Elements and lifecycle

Custom Elements let you define your own HTML tag, backed by a JavaScript class. The browser calls lifecycle callbacks like connectedCallback when the element is inserted into the DOM, and disconnectedCallback when it’s removed. Use connectedCallback to set up event listeners or fetch initial data; clean up in disconnectedCallback to avoid memory leaks.

Example: a simple button that handles clicks and respects a disabled state. In real projects, this would likely go through a build pipeline and live inside a component library.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Web Components Demo</title>
</head>
<body>
  <my-button label="Click me"></my-button>
  <script type="module" src="./dist/my-button.js"></script>
</body>
</html>
// src/my-button.js
export class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['label', 'disabled'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._disabled = false;
    this._onClick = () => {
      if (this._disabled) return;
      this.dispatchEvent(new CustomEvent('my-button-click', {
        bubbles: true,
        composed: true,
        detail: { timestamp: Date.now() }
      }));
    };
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('button').addEventListener('click', this._onClick);
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button')?.removeEventListener('click', this._onClick);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'disabled') {
      this._disabled = newVal !== null;
      this.updateState();
    } else if (name === 'label') {
      this.updateLabel(newVal);
    }
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-block; }
        button {
          background: #2563eb;
          color: white;
          border: none;
          padding: 0.5rem 0.75rem;
          border-radius: 0.25rem;
          cursor: pointer;
          font-weight: 600;
        }
        button[disabled] { background: #94a3b8; cursor: not-allowed; }
      </style>
      <button type="button">${this.getAttribute('label') || 'Button'}</button>
    `;
  }

  updateLabel(label) {
    const btn = this.shadowRoot.querySelector('button');
    if (btn) btn.textContent = label || 'Button';
  }

  updateState() {
    const btn = this.shadowRoot.querySelector('button');
    if (!btn) return;
    if (this._disabled) btn.setAttribute('disabled', '');
    else btn.removeAttribute('disabled');
  }
}

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

Fun fact: you can call customElements.define multiple times with the same tag in development while experimenting, but once it’s defined, you can’t redefine it. This is why you’ll often see guard checks in hot-module replacement scenarios.

Shadow DOM for style encapsulation

Shadow DOM scopes styles and markup to the component, preventing global CSS from bleeding in. That’s invaluable when integrating with legacy apps or design systems that use conflicting class names.

In production, I’ve seen a button component survive a complete redesign of the host app’s CSS because its styles lived inside the shadow root. But note: Shadow DOM is not a security boundary; it’s a style and DOM encapsulation tool.

For design systems, you often want a consistent design token system across components. The typical approach is to expose CSS custom properties (variables) from the host page and consume them inside the shadow root. This way, theming stays centralized.

// src/themed-card.js
export class ThemedCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .card {
          background: var(--card-bg, white);
          border: 1px solid var(--card-border, #e2e8f0);
          border-radius: 0.5rem;
          padding: 1rem;
          color: var(--card-fg, #0f172a);
          box-shadow: var(--card-shadow, none);
        }
        .title { font-weight: 700; margin-bottom: 0.5rem; }
      </style>
      <div class="card">
        <div class="title"><slot name="title">Card Title</slot></div>
        <div class="content"><slot>Card content</slot></div>
      </div>
    `;
  }
}

customElements.define('themed-card', ThemedCard);

You can set variables on the host to theme all components uniformly:

<!-- index.html -->
<style>
  :root {
    --card-bg: #f8fafc;
    --card-border: #cbd5e1;
    --card-fg: #1e293b;
    --card-shadow: 0 1px 2px rgba(0,0,0,0.08);
  }
</style>

<themed-card>
  <span slot="title">Welcome</span>
  This card respects the host’s design tokens.
</themed-card>

Templates and slots for flexible content

HTML templates are inert fragments you can clone and insert. Slots allow consumers to pass content into your component, much like children in React or named slots in Vue. This pattern is central to creating generic components that don’t over-prescribe content.

For example, a list component that renders whatever you pass in, without assuming the shape of items. This approach has saved me from creating one-off list variants for every product page.

// src/generic-list.js
export class GenericList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        ul { list-style: none; padding: 0; margin: 0; }
        li { padding: 0.5rem 0; border-bottom: 1px solid var(--border, #e2e8f0); }
        li:last-child { border-bottom: none; }
      </style>
      <ul>
        <slot name="item"></slot>
      </ul>
    `;
  }
}

customElements.define('generic-list', GenericList);

Usage, where each item is provided by the consumer via a named slot:

<generic-list>
  <li slot="item">First item</li>
  <li slot="item">Second item</li>
  <li slot="item">Third item</li>
</generic-list>

Handling attributes, properties, and events

Attributes are the HTML-facing API; properties are the JavaScript API. For complex data, prefer properties and reflect attributes only when it makes sense for the HTML representation.

In my projects, I keep attributes for simple flags and strings, and expose typed properties for objects or arrays. For events, use CustomEvent with composed: true so events cross the Shadow DOM boundary.

// src/data-table.js
export class DataTable extends HTMLElement {
  set rows(val) {
    this._rows = Array.isArray(val) ? val : [];
    this.render();
  }
  get rows() {
    return this._rows || [];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._rows = [];
    this._onRowClick = (e) => {
      const tr = e.target.closest('tr');
      if (!tr) return;
      const index = Number(tr.dataset.index);
      const item = this.rows[index];
      this.dispatchEvent(new CustomEvent('row-select', {
        detail: { item, index },
        bubbles: true,
        composed: true
      }));
    };
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('table').addEventListener('click', this._onRowClick);
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('table')?.removeEventListener('click', this._onRowClick);
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        table { width: 100%; border-collapse: collapse; }
        th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #e2e8f0; }
        tr:hover { background: #f1f5f9; cursor: pointer; }
      </style>
      <table>
        <thead>
          <tr><th>Name</th><th>Status</th></tr>
        </thead>
        <tbody>
          ${this.rows.map((row, i) => `
            <tr data-index="${i}">
              <td>${row.name}</td>
              <td>${row.status}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
    `;
  }
}

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

Usage with property assignment from JavaScript:

<data-table id="users"></data-table>

<script type="module">
  const table = document.getElementById('users');
  table.rows = [
    { name: 'Ada', status: 'Active' },
    { name: 'Linus', status: 'Away' },
    { name: 'Grace', status: 'Inactive' }
  ];

  table.addEventListener('row-select', (e) => {
    console.log('Selected row:', e.detail.item);
  });
</script>

Project structure and tooling

I generally recommend a small build pipeline for production components. While you can author pure JavaScript and ship it, you’ll want TypeScript for robustness, a bundler for modern module output, and a test runner for regression protection.

Here’s a minimal, real-world folder structure you might use:

my-web-component-lib/
├── package.json
├── tsconfig.json
├── vite.config.js
├── index.html           # demo page for local dev
├── src/
│   ├── my-button.ts
│   ├── themed-card.ts
│   ├── generic-list.ts
│   └── index.ts         # barrel to export components
├── dist/                # build output
└── tests/
    ├── my-button.test.ts
    └── setup.ts

For the build, I like Vite because it’s fast, supports modern ES modules out of the box, and provides a great dev server. For testing, Web Test Runner (from Modern Web) is lightweight and runs in real browsers.

Example configuration:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: './src/index.ts',
      formats: ['es'],
      fileName: 'my-web-components'
    },
    rollupOptions: {
      external: []
    }
  },
  server: {
    open: true
  }
});
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["DOM", "ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "node",
    "declaration": true,
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
// package.json
{
  "name": "my-web-components",
  "version": "0.1.0",
  "type": "module",
  "main": "dist/my-web-components.js",
  "files": ["dist"],
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "web-test-runner \"tests/**/*.test.ts\" --node-resolve --esbuild-target auto"
  },
  "devDependencies": {
    "vite": "^5.4.0",
    "typescript": "^5.3.0",
    "@web/test-runner": "^0.18.0",
    "@esm-bundle/chai": "^4.3.4"
  }
}

For TypeScript users, extend HTMLElement to get types for lifecycle callbacks and attribute reflections. A common pattern is to place properties that should reflect to attributes behind getter/setter pairs and call this.setAttribute when reflecting. Remember: not all properties should be reflected. Keep the HTML surface area minimal to avoid coupling.

Async data and loading patterns

Most real components need data. In Web Components, you can fetch data inside connectedCallback or via a method called from the outside. I prefer exposing a public method or property setter to trigger loading, so the host can control lifecycle and error handling. This avoids hardcoding API endpoints inside the component, which makes testing and reuse easier.

Here’s an example of an async list component that renders skeletons while loading and surfaces errors to the host via events. This is a pattern I’ve used in dashboards where we want to keep loading, error, and content concerns separate.

// src/async-list.js
export class AsyncList extends HTMLElement {
  static get observedAttributes() {
    return ['src'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._src = null;
    this._data = null;
    this._loading = false;
  }

  get src() {
    return this._src;
  }

  set src(value) {
    this._src = value;
    if (this.isConnected) this.load();
  }

  connectedCallback() {
    this.render();
    this.load();
  }

  async load() {
    if (!this._src) return;
    this._loading = true;
    this.render();

    try {
      const res = await fetch(this._src);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      this._data = data;
      this._loading = false;
      this.render();
      this.dispatchEvent(new CustomEvent('async-list:loaded', {
        detail: { data },
        bubbles: true,
        composed: true
      }));
    } catch (err) {
      this._loading = false;
      this.render();
      this.dispatchEvent(new CustomEvent('async-list:error', {
        detail: { error: err.message },
        bubbles: true,
        composed: true
      }));
    }
  }

  render() {
    const content = this._loading
      ? `<div class="skeleton">Loading…</div>`
      : Array.isArray(this._data)
        ? this._data.map(item => `<li>${item.name}</li>`).join('')
        : `<div class="empty">No data</div>`;

    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .skeleton {
          animation: pulse 1.5s ease-in-out infinite;
          background: #e2e8f0;
          color: #475569;
          padding: 0.5rem;
          border-radius: 0.25rem;
        }
        @keyframes pulse { 0% { opacity: 0.7 } 50% { opacity: 1 } 100% { opacity: 0.7 } }
        ul { list-style: none; padding: 0; margin: 0; }
        li { padding: 0.5rem 0; border-bottom: 1px solid #e2e8f0; }
        .empty { color: #64748b; padding: 0.5rem; }
      </style>
      <ul>${content}</ul>
    `;
  }
}

customElements.define('async-list', AsyncList);

Usage, where the host provides a URL and listens for events:

<async-list id="list" src="https://api.example.com/users"></async-list>

<script type="module">
  const list = document.getElementById('list');
  list.addEventListener('async-list:error', (e) => {
    console.error('List failed:', e.detail.error);
  });
  list.addEventListener('async-list:loaded', (e) => {
    console.log('List loaded:', e.detail.data.length);
  });
</script>

In production, I often add AbortController support to cancel in-flight requests when the component is disconnected. This prevents race conditions when a user navigates away quickly.

Accessibility in Web Components

Accessibility should be baked in from the start. I’ve found a few practices make a big difference:

  • Use semantic elements inside the shadow root when possible (button, input, a).
  • Manage focus thoughtfully. If your component opens a dialog, move focus to the dialog and trap it until closed.
  • Expose states via ARIA attributes where appropriate, like aria-disabled, aria-expanded, aria-live.
  • Ensure events and keyboard interactions match expectations. For buttons, respond to Enter and Space.

Here’s a small improvement to our my-button to support keyboard interaction and aria-pressed for a toggle variant.

// src/my-button.ts (snippets)
export class MyButton extends HTMLElement {
  // ... previous code ...

  connectedCallback() {
    this.render();
    const btn = this.shadowRoot.querySelector('button');
    btn.addEventListener('click', this._onClick);
    btn.addEventListener('keydown', this._onKeydown);
  }

  disconnectedCallback() {
    const btn = this.shadowRoot.querySelector('button');
    btn?.removeEventListener('click', this._onClick);
    btn?.removeEventListener('keydown', this._onKeydown);
  }

  _onKeydown = (e) => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault();
      this._onClick();
    }
  };
}

For accessible announcements, I often add a small aria-live region inside the shadow DOM to announce status changes. However, note that some screen readers are picky about live regions inside shadow DOM; test across engines.

Styling considerations

Styling Web Components is a mix of encapsulated styles and theme exposure. A few patterns I use consistently:

  • Use CSS custom properties for theming. Document the variables your components consume.
  • Avoid deep selectors in shadow DOM. Instead, expose slots for user-provided content.
  • When embedding in design systems, mirror tokens. Some libraries like Shoelace style their components with tokens that you can override: https://shoelace.style/

For example, you can allow users to style slotted content from outside:

<themed-card>
  <span slot="title" style="color: #16a34a;">Green Title</span>
  This text can be styled by the host app.
</themed-card>

Testing Web Components

Testing Web Components requires a real browser environment to exercise Custom Elements and Shadow DOM correctly. I recommend using Web Test Runner or Playwright. Here’s a simple test using Web Test Runner and Chai.

// tests/my-button.test.ts
import { expect } from '@esm-bundle/chai';
import '../src/my-button.js';

describe('my-button', () => {
  it('renders label attribute', async () => {
    const el = document.createElement('my-button');
    el.setAttribute('label', 'Test Label');
    document.body.appendChild(el);

    await new Promise(resolve => requestAnimationFrame(resolve));
    const btn = el.shadowRoot?.querySelector('button');
    expect(btn?.textContent).to.equal('Test Label');

    el.remove();
  });

  it('fires custom event on click', async () => {
    const el = document.createElement('my-button');
    el.setAttribute('label', 'Click Me');
    document.body.appendChild(el);

    let captured;
    el.addEventListener('my-button-click', (e) => { captured = e; });

    const btn = el.shadowRoot?.querySelector('button');
    btn?.click();

    await new Promise(resolve => requestAnimationFrame(resolve));
    expect(captured).to.not.be.undefined;
    expect(captured.detail.timestamp).to.be.a('number');

    el.remove();
  });
});

For CI, run tests in headless mode. Since Web Components rely on browser APIs, avoid relying solely on JSDOM-based test runners.

Integrating with frameworks

I’ve integrated Web Components into React, Vue, and Angular without much trouble. The main watch-outs:

  • Event naming: use custom events and avoid clashing with native ones.
  • Properties vs attributes: pass complex data via properties, not attributes.
  • React note: React does not yet pass custom event listeners to custom elements as properties, so attach them as standard DOM event listeners.

Example: using a data-table inside React.

// React usage snippet
import React, { useEffect, useRef } from 'react';

export function UserList() {
  const tableRef = useRef(null);

  useEffect(() => {
    const el = tableRef.current;
    el.rows = [
      { name: 'Ada', status: 'Active' },
      { name: 'Linus', status: 'Away' },
      { name: 'Grace', status: 'Inactive' }
    ];

    const handler = (e) => {
      console.log('Selected row in React:', e.detail.item);
    };
    el.addEventListener('row-select', handler);
    return () => el.removeEventListener('row-select', handler);
  }, []);

  return <data-table ref={tableRef}></data-table>;
}

When embedding components in server-rendered pages, ensure you don’t rely on browser APIs at import time. Defer interactions to connectedCallback or runtime detection.

Strengths, weaknesses, and tradeoffs

Strengths:

  • Framework-agnostic: usable anywhere, including legacy apps and embeddable widgets.
  • Longevity: built on web standards, less likely to break with library upgrades.
  • Encapsulation: Shadow DOM protects styles and DOM structure in complex apps.
  • Lightweight: no framework runtime required.

Weaknesses and tradeoffs:

  • Developer ergonomics: React hooks, Vue reactivity, and Svelte transitions are not available. You’ll write vanilla JS or pair with a lightweight reactivity library.
  • SSR: rendering Web Components on the server isn’t straightforward. You often need progressive enhancement or a separate rendering path.
  • Testing complexity: requires real browsers and thoughtful setup.
  • Polyfills: if you must support older browsers, you’ll need to manage polyfills carefully.

When to use Web Components:

  • Design systems shared across multiple stacks.
  • Embeddable widgets for partners or third-party sites.
  • Incremental modernization where you want UI primitives independent of frameworks.

When to skip:

  • If your entire app is firmly in one modern framework and you rely heavily on framework-specific features and SSR strategies.
  • If you need complex animations or transitions that rely on a framework’s virtual DOM or compiler features.
  • If your team has no appetite for vanilla JS patterns or testing in real browsers.

Personal experience and common mistakes

I once introduced a set of Web Components into a React-heavy monorepo to standardize a shared date picker. The biggest win was the longevity: we swapped the React version for a Vue-based dashboard later, and the date picker still worked with minimal changes. The biggest mistake I made initially was overloading attributes with complex JSON strings, which made for a brittle API and hard-to-read HTML. We fixed it by exposing typed properties and keeping attributes for simple flags.

Another pitfall: forgetting to clean up event listeners in disconnectedCallback. In a high-frequency SPA, this led to duplicate listeners and subtle bugs. Now, I always pair addEventListener with removeEventListener using stable references.

When working with Shadow DOM, avoid assuming global styles will propagate. Instead, design your component to accept theme tokens via CSS custom properties. Document them clearly. In practice, teams that do this maintain a healthy separation between host app theming and component internals.

Finally, async components need explicit states: loading, loaded, error. I’ve learned to surface these as events so the host can decide how to show spinners or error messages, keeping the component focused and flexible.

Getting started workflow

Here’s a workflow I use when spinning up a new Web Component library:

  • Scaffold a repo with TypeScript and Vite. Define a build target of modern ES.
  • Create a simple demo page (index.html) that imports components via modules for local dev.
  • Set up tests with Web Test Runner early. It’s easier to maintain than retrofit.
  • Document your component APIs: attributes, properties, events, and CSS custom properties.
  • Use a barrel file (src/index.ts) to export all components for consumers.
  • Consider publishing as ES modules only, and let consumers import the specific components they need.

Example barrel:

// src/index.ts
export { MyButton } from './my-button.js';
export { ThemedCard } from './themed-card.js';
export { GenericList } from './generic-list.js';

A minimal demo page might look like:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Component Demo</title>
  <style>
    :root {
      --card-bg: #ffffff;
      --card-border: #d1d5db;
      --card-fg: #111827;
      --card-shadow: 0 4px 10px rgba(0,0,0,0.06);
    }
    body { font-family: system-ui, sans-serif; padding: 2rem; }
    section { margin-bottom: 2rem; }
  </style>
</head>
<body>
  <section>
    <h2>Button</h2>
    <my-button label="Primary Action"></my-button>
  </section>
  <section>
    <h2>Card</h2>
    <themed-card>
      <span slot="title">Themed Title</span>
      Card content slotted from the host.
    </themed-card>
  </section>
  <section>
    <h2>Async List</h2>
    <async-list src="https://api.example.com/users"></async-list>
  </section>
  <script type="module" src="./dist/my-web-components.js"></script>
</body>
</html>

Free learning resources

Summary: who should use Web Components and when

Use Web Components if:

  • You need UI primitives that outlive any single framework.
  • You’re building a design system for multiple apps or teams.
  • You’re creating embeddable widgets that must work on unknown host pages.
  • You want a lean runtime without a framework dependency.

Consider skipping if:

  • Your app heavily relies on framework-specific SSR, streaming, or advanced reactive patterns.
  • You don’t want to maintain vanilla JS patterns and browser-based testing.
  • Your team is fully committed to one modern framework and has no plans to diversify.

Web Components are not a silver bullet, but they solve a class of problems elegantly: shareable, stable, standards-based UI. I’ve found them especially valuable in messy, heterogeneous environments where frameworks change but the UI must keep working. If you value long-term maintainability and framework independence, they’re worth the investment.