Skip to main content
Frontend7 min readJune 15, 2025

Web Components: Building Reusable Custom Elements

Web components let you create framework-agnostic reusable elements with encapsulated styles and behavior. Here's when they make sense and how to build them well.

James Ross Jr.
James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

What Web Components Actually Solve

Web components are a set of browser-native APIs that let you create custom HTML elements with encapsulated functionality and styling. They consist of three specifications: Custom Elements (defining new HTML tags), Shadow DOM (encapsulated styling and markup), and HTML Templates (<template> and <slot> for declarative composition).

The pitch is framework independence. A web component written with vanilla browser APIs works in React, Vue, Angular, Svelte, plain HTML, or any other environment that renders to the DOM. Build once, use everywhere. That pitch is accurate but comes with caveats that determine whether web components are the right tool for a given project.

Web components solve a specific problem: sharing UI elements across different technology stacks. If your organization has teams using React, Vue, and Angular on different products, and all those products need to share a design system — buttons, form inputs, navigation components, data tables — web components provide a single implementation that works everywhere. Without web components, you would need to maintain three separate implementations of every design system component, one per framework.

They also solve the problem of embeddable widgets. If you build a component that third parties embed on their sites (a chat widget, a booking calendar, an analytics dashboard), web components with Shadow DOM ensure your styles do not leak into the host page and the host page's styles do not break your widget.

Where web components are not the right choice: within a single framework application. If your entire application is built with Vue, using web components instead of Vue components sacrifices the framework's reactivity system, its component model, its devtools integration, and its ecosystem of compatible libraries. Within a single framework, use that framework's component model. Web components are a bridge between frameworks, not a replacement for them.


Building a Custom Element

A custom element is a JavaScript class that extends HTMLElement and is registered with a hyphenated tag name. The class defines lifecycle callbacks that the browser calls at specific moments: connectedCallback when the element is added to the DOM, disconnectedCallback when it is removed, and attributeChangedCallback when an observed attribute changes.

The observedAttributes static property declares which HTML attributes the component watches for changes. When one of these attributes changes, the component can re-render itself with the new values. This is the web component equivalent of reactive props in Vue or React.

Shadow DOM provides style encapsulation. Calling attachShadow({ mode: 'open' }) in the constructor creates a shadow root where you place your component's internal markup and styles. Styles defined inside the shadow root do not leak out, and global styles do not leak in. This encapsulation is what makes web components safe to embed in third-party pages.

Usage in any HTML context is straightforward:

<status-badge status="active" label="Online"></status-badge>
<status-badge status="pending" label="Processing"></status-badge>

This component works identically whether dropped into a React app, a Vue app, a static HTML page, or any other context. Custom elements must have a hyphenated name to distinguish them from native HTML elements.

For rendering the shadow DOM content, modern approaches include using template literals with the shadow root, or using libraries like Lit that provide a declarative rendering layer on top of the native APIs. Lit adds only 5KB to your bundle while providing reactive properties, declarative templates, and efficient DOM updates — it is essentially the "framework" for web components.


Shadow DOM: Encapsulation with Tradeoffs

Shadow DOM creates a separate DOM tree attached to your element. Styles inside the Shadow DOM do not affect the outside page, and outside styles do not penetrate the Shadow DOM. This is powerful for isolation but creates friction with global design systems, Tailwind CSS, and CSS frameworks that rely on global class names.

You cannot use Tailwind utility classes inside a Shadow DOM because the Tailwind stylesheet is in the global scope. You would need to inject the Tailwind stylesheet into each Shadow DOM, which duplicates the entire stylesheet per component instance. For design systems that rely on global CSS, this is a genuine obstacle.

Several strategies address this. CSS Custom Properties (variables) do cross the Shadow DOM boundary — they cascade from the global scope into shadow roots. Define your design tokens as custom properties on :root or :host, and reference them inside your shadow styles:

/* Global scope */
:root {
 --color-primary: #3b82f6;
 --radius-md: 0.375rem;
 --font-sans: 'Inter', system-ui, sans-serif;
}

/* Inside Shadow DOM */
:host {
 font-family: var(--font-sans);
}

.button {
 background: var(--color-primary);
 border-radius: var(--radius-md);
}

The ::part() pseudo-element exposes specific internal elements for external styling. You mark an element with a part attribute inside the shadow root, and external CSS can target it with element-name::part(part-name). This provides controlled styling access without breaking encapsulation entirely.

For cases where style encapsulation creates more problems than it solves, you can skip Shadow DOM entirely. A custom element without attachShadow() renders its content in the regular DOM, fully accessible to global styles. You lose encapsulation but gain compatibility with CSS frameworks.


When to Choose Web Components

The decision matrix is practical. Use web components when you need to share UI elements across multiple frameworks or technology stacks, when you are building embeddable third-party widgets, or when you are creating low-level primitives (buttons, inputs, badges) for a multi-platform design system.

Use framework components when you are building within a single framework, when you need tight integration with framework-specific features (reactivity, state management, routing), or when your team's expertise and tooling are framework-centric.

Web components and framework components are not mutually exclusive. Many design systems wrap web components in thin framework adapters — a Vue wrapper that provides v-model support, a React wrapper that maps React events to custom events. This gives you the portability of web components with the developer experience of framework-native components.

For full-stack applications built with a single framework, the overhead of web components is rarely justified. For organizations managing multiple applications across different stacks, web components can dramatically reduce the cost of maintaining a consistent UI. The technology choice follows from the organizational context, not from technical superiority.

Testing web components requires some adjustment. Standard testing tools like Vitest work well for the JavaScript logic, but testing Shadow DOM content requires querying through the shadowRoot property rather than standard DOM queries. Libraries like @open-wc/testing provide utilities specifically for web component testing, including fixture helpers, assertion extensions, and accessibility testing for shadow DOM content.

Build web components when they solve a real cross-platform problem. Use framework components when they do not. The web platform gives you both tools — the skill is knowing which problem each one solves.