Web Components + React: Custom Elements Without the Headaches
Using Web Components in React is trickier than it should be. Here's how to handle custom elements, events, and props without losing your mind or your bundle size.
Why React and Web Components Still Don't Fully Get Along
Honestly, the friction between React and Web Components is one of those things the community has been complaining about for years, and for good reason. React was designed around a virtual DOM, JavaScript-driven data flow, and synthetic events. Web Components are a browser-native spec built around real DOM, HTML attributes, and native custom events. These two philosophies collide in frustrating ways that aren't obvious until you're two hours into debugging an event handler that silently swallows every click.
The good news is React 19 made real progress here. The React team finally treated custom elements as first-class citizens, fixing the long-standing issue where React couldn't reliably pass non-primitive props to custom element attributes. If you're still on React 18, you'll need workarounds — and we'll cover those. But if you've upgraded, some of this pain is already behind you.
This article is about the practical stuff: how to wrap a Web Component cleanly in React, how to handle custom events, how to deal with Shadow DOM styling conflicts, and when it actually makes sense to use Web Components at all in a React project. No theory-first, no framework philosophy debates.
The Core Problem: Props vs Attributes in Custom Elements
Here's the thing: HTML attributes are always strings. That's the browser spec. So when you write <my-slider value={42} /> in JSX, React on versions prior to 19 would try to serialize that number to an attribute — and depending on how your custom element was written, it might just silently get "42" as a string instead of the number 42. For primitive types that's annoying but recoverable. For arrays and objects, it's a disaster.
React 19 addresses this by checking whether a property exists on the custom element's prototype before deciding whether to set it as a DOM property or an HTML attribute. If your custom element exposes value as a JavaScript property, React sets it directly. If not, it falls back to setAttribute. This is the correct behavior, but it means your custom elements need to be property-first in their design — not just attribute-first.
Before React 19, the safest pattern was to use a ref and set properties imperatively in a useEffect. It's verbose, but it works reliably across all element implementations:
import { useRef, useEffect } from 'react';
interface MySliderProps {
value: number;
min?: number;
max?: number;
onValueChange?: (value: number) => void;
}
export function SliderWrapper({ value, min = 0, max = 100, onValueChange }: MySliderProps) {
const ref = useRef<HTMLElement & { value: number; min: number; max: number }>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.value = value;
el.min = min;
el.max = max;
}, [value, min, max]);
useEffect(() => {
const el = ref.current;
if (!el || !onValueChange) return;
const handler = (e: Event) => {
const custom = e as CustomEvent<{ value: number }>;
onValueChange(custom.detail.value);
};
el.addEventListener('value-change', handler);
return () => el.removeEventListener('value-change', handler);
}, [onValueChange]);
return <my-slider ref={ref} />;
}This pattern — wrap, ref, useEffect — is the standard migration path when you need React 18 compatibility. It's not elegant, but it's explicit and testable.
Custom Events Are Not React Events
React's synthetic event system doesn't know about custom events fired via new CustomEvent('my-event', { detail: { ... }, bubbles: true }). You can't write onMyEvent={handler} in JSX and expect it to work — React only maps synthetic events to a fixed list of native events. Everything else needs addEventListener.
The ref pattern above handles this correctly. But there's a subtlety worth flagging: if your custom element dispatches non-bubbling events, you need to attach the listener directly to the element, not to a parent wrapper. A lot of Web Component libraries default to bubbles: false for performance reasons. Check the docs of whatever element you're consuming.
One practical improvement is to write a generic hook for this instead of repeating the event wiring in every wrapper component. Something like useCustomEvent(ref, 'value-change', handler) keeps things DRY and means your wrapper components stay focused on mapping props, not plumbing DOM events.
For anyone building theme-aware component systems, this event isolation also means you need to be deliberate about propagating theme changes into Shadow DOM — which is a whole separate problem we get into in the Shadow DOM section below.
Shadow DOM and CSS: Styling Across the Boundary
Shadow DOM encapsulation is the feature, not a bug. But it absolutely will break your Tailwind styles. The Shadow DOM creates a style boundary — your global stylesheet, your Tailwind utility classes, all of it stops at that line. Whatever styles are inside the shadow root come from the component's own shadow stylesheet, and nothing from outside can pierce through (unless the component author explicitly allowed it via CSS custom properties or ::part()).
If you're building your own Web Components alongside your React app, the cleanest approach is to expose theming via CSS custom properties. Define your design tokens at the :root level and have the shadow stylesheet consume them. This way your Tailwind configuration (or your glassmorphism tokens) flow through naturally:
/* Global stylesheet — works inside Shadow DOM */
:root {
--color-primary: #6366f1;
--color-surface: rgba(255, 255, 255, 0.15);
--radius-card: 12px;
--gap-default: 8px;
}
/* Inside the Web Component's shadow stylesheet */
:host {
display: block;
background: var(--color-surface);
border-radius: var(--radius-card);
padding: var(--gap-default);
color: var(--color-primary);
}The ::part() pseudo-element is your other option. If the component exposes parts like ::part(button) or ::part(panel), you can style those from outside the shadow root. It's more surgical than CSS custom properties and works well when you need precise control without requiring the component author to expose every style hook as a variable.
What doesn't work: trying to inject a <style> tag into the shadow root from React, targeting shadow DOM elements with Tailwind classes, or hoping that CSS-in-JS solutions magically solve this. They don't. If you're using Tailwind vs CSS Modules as your styling strategy, the shadow DOM boundary means you'll need CSS custom properties regardless of which you've chosen for your React components.
Writing a Proper React Wrapper for Third-Party Web Components
Most of the time you're consuming someone else's Web Component — a design system from a different team, a map widget, a data grid. The wrapper pattern is your friend here. The goal is to create a React component that feels idiomatic to React consumers while hiding all the DOM imperative messiness inside.
There are a few things a good wrapper should handle: prop-to-property mapping, custom event to callback mapping, forwarding refs if the consumer might need direct DOM access, and TypeScript types that accurately describe what the component accepts. Skipping the types is tempting when you're moving fast, but you'll regret it the moment you have five different wrappers that all do slightly different things.
import { forwardRef, useRef, useEffect, useImperativeHandle, useCallback } from 'react';
// Declare the custom element for TypeScript
declare global {
namespace JSX {
interface IntrinsicElements {
'ui-data-grid': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
'page-size'?: number;
loading?: boolean;
};
}
}
}
interface DataGridProps {
rows: Record<string, unknown>[];
columns: { key: string; label: string }[];
pageSize?: number;
loading?: boolean;
onRowSelect?: (row: Record<string, unknown>) => void;
onPageChange?: (page: number) => void;
}
export const DataGrid = forwardRef<HTMLElement, DataGridProps>((
{ rows, columns, pageSize = 25, loading = false, onRowSelect, onPageChange },
forwardedRef
) => {
const innerRef = useRef<HTMLElement>(null);
useImperativeHandle(forwardedRef, () => innerRef.current!);
// Set complex props as DOM properties
useEffect(() => {
const el = innerRef.current as any;
if (!el) return;
el.rows = rows;
el.columns = columns;
}, [rows, columns]);
const handleRowSelect = useCallback((e: Event) => {
const { detail } = e as CustomEvent;
onRowSelect?.(detail.row);
}, [onRowSelect]);
const handlePageChange = useCallback((e: Event) => {
const { detail } = e as CustomEvent;
onPageChange?.(detail.page);
}, [onPageChange]);
useEffect(() => {
const el = innerRef.current;
if (!el) return;
el.addEventListener('row-select', handleRowSelect);
el.addEventListener('page-change', handlePageChange);
return () => {
el.removeEventListener('row-select', handleRowSelect);
el.removeEventListener('page-change', handlePageChange);
};
}, [handleRowSelect, handlePageChange]);
return (
<ui-data-grid
ref={innerRef}
page-size={pageSize}
loading={loading || undefined}
/>
);
});
DataGrid.displayName = 'DataGrid';Notice a few things here: loading is passed as undefined when false to avoid setting the attribute at all (some Web Components check for attribute presence, not value). Array/object props like rows and columns go through useEffect with the DOM property approach. And forwardRef + useImperativeHandle means consumers can still grab the underlying element if they need it for something like el.scrollToRow(50).
When to Actually Use Web Components in a React Project
Here's an honest take: if your entire stack is React, you probably don't need Web Components. React already gives you component encapsulation, composability, and a prop system. Adding Web Components to a React-only project just introduces the friction we've spent this whole article solving.
Where Web Components genuinely shine is at organization boundaries. Your design system team wants to ship components that work in the React app, the Vue microservice, the legacy jQuery admin panel, and the vanilla HTML marketing site. Web Components are the right answer there — write once, use anywhere, no framework dependency. The interop cost you pay in React is worth it when the alternative is maintaining four framework-specific component libraries.
They also make sense for genuinely browser-native things: custom form controls that participate in form validation via ElementInternals, custom media elements, or anything that needs to hook into browser APIs that React's event delegation model makes awkward. For those cases, wrapping a well-built Web Component in React is significantly less work than replicating the browser API surface in pure React.
Is your design system moving toward this cross-framework model? If you're already thinking about advanced visual effects like CSS Houdini paint worklets or WebGL backgrounds, Web Components can be an excellent delivery mechanism — the Shadow DOM actually helps isolate the heavy canvas or WebGL setup from the rest of your React rendering tree.
TypeScript Declaration Files for Custom Elements
One of the most annoying parts of working with third-party Web Components in TypeScript is the lack of type information. You get Property 'rows' does not exist on type 'HTMLElement' errors everywhere, and JSX won't recognize your custom tag names. The fix is declaration merging — you extend the JSX namespace and the HTMLElementTagNameMap.
Most modern Web Component libraries now ship a custom-elements.d.ts or global.d.ts that does this automatically. Check the package's exports field in package.json for type entry points before writing your own declarations. Libraries built with Lit v3.x, for example, generate accurate type declarations if the author uses the @customElement decorator and explicitly types their reactive properties.
If you're writing your own custom elements, the @web/custom-elements-manifest analyzer can auto-generate these declarations from your source, and tools like the VS Code Custom Elements LSP plugin will give you IntelliSense directly in HTML files. Worth setting up if you're shipping a design system that others consume.
Server-Side Rendering and Hydration Gotchas
Web Components and SSR are still genuinely hard. The custom elements registry (customElements.define) only exists in the browser — it's not available in Node. This means any Web Component you render on the server will produce just an unknown HTML element tag with no shadow root, no styles, no functionality. When React hydrates on the client, it'll try to reconcile that bare element with what the browser has upgraded it to, and things can get weird.
The practical solution for Next.js or Remix apps is to load Web Components with dynamic import and { ssr: false }. Yes, this means those components won't appear in the initial HTML. For content that needs to be indexed by search engines or visible without JavaScript, this is a real problem. For interactive widgets that only make sense client-side anyway, it's a reasonable trade-off.
Declarative Shadow DOM (<template shadowrootmode="open">) is the emerging spec answer to this, and some frameworks are starting to support it. It lets you ship pre-rendered shadow root content in static HTML. Browser support as of late 2026 is solid across Chromium and Firefox, with Safari support from v17.4. It's worth watching, especially if you're building components that need both SSR and shadow DOM encapsulation.
FAQ
Mostly yes. React 19 correctly sets DOM properties on custom elements (not just string attributes) and handles boolean attributes properly. The main remaining gap is custom events — React still won't map onMyCustomEvent to an addEventListener call. You'll still need the ref + useEffect pattern for custom event handling regardless of React version.
Use a ref and set the property imperatively in a useEffect. HTML attributes can only be strings, so doing <my-el items={myArray} /> will either serialize to '[object Object]' or fail silently. The only safe path is el.items = myArray via the DOM property interface.
Shadow DOM creates a style boundary. Your global stylesheet and Tailwind output don't cross it. The solution is to use CSS custom properties (variables) at the :root level and consume them inside the shadow stylesheet, or use the ::part() pseudo-element if the component exposes named parts.
Yes, but only as client components. Add 'use client' to any file that imports or renders a Web Component, and use dynamic import with ssr: false to prevent server-side errors since customElements.define doesn't exist in Node. The component won't appear in the initial HTML, but it'll hydrate correctly on the client.
Extend the JSX.IntrinsicElements interface in a .d.ts file with your element names and their prop types. Also extend HTMLElementTagNameMap if you want querySelector calls to return typed results. Many Web Component libraries ship these declarations automatically — check the package's type entry point first.
If you're using the element more than once or it has complex event/property requirements, yes. Wrappers centralize the imperative DOM logic, give you a place to put TypeScript types, and present a React-idiomatic API to the rest of your team. For simple, self-contained elements with only string attributes, direct use in JSX with ref for events is fine.