Web Components Interoperability

TL;DR / Web components interop with frameworks requires bridging the gap between HTML attributes/DOM events (the web component API) and framework-specific reactivity systems (props, synthetic events, templates).

How It Works

 ┌─────────┐       ┌─────────┐       ┌─────────┐
 │  React  │       │   Vue   │       │ Angular │
 └─────────┘       └─────────┘       └─────────┘
      │                 │                 │
      └─────────────────┌─────────────────┘
                        ↓
     ┌─────────────────────────────────────┐
     │          Wrapper / Adapter          │
     └─────────────────────────────────────┘
                        │
                        │
                        ↓
     ┌─────────────────────────────────────┐
     │          <custom-element>           │
     └─────────────────────────────────────┘

           Properties + Events + Slots

Edit diagram

Web components use the platform's native API surface: HTML attributes, DOM properties, DOM events, and slots. Frameworks use their own abstractions: React has JSX props and synthetic events, Vue has v-bind and v-on directives, Angular has input/output bindings and content projection. The interop challenge is mapping between these two worlds without losing reactivity, type safety, or developer ergonomics.

The Attribute vs. Property Problem

This is the core friction point. HTML attributes are always strings, set via element.setAttribute('count', '5'). DOM properties can be any type, set via element.count = 5. Web components typically need both: attributes for declarative HTML usage, and properties for programmatic JavaScript usage.

React (prior to version 19) always set attributes via setAttribute(), not properties. This meant passing objects, arrays, or functions to a web component was impossible through JSX — React would serialize them to "[object Object]" as a string attribute. The workaround was using a ref to set properties imperatively: ref.current.data = myObject. React 19 changed this behavior: it now checks whether a property exists on the element's prototype and sets properties directly when they do, falling back to attributes otherwise.

Vue has handled this correctly for longer. It uses a heuristic: if a property exists on the element's DOM interface, Vue sets the property. Otherwise, it uses setAttribute. You can also force the behavior with the .prop modifier (v-bind:count.prop="5") or the shorthand (:count.prop="5"). Vue 3's compilerOptions.isCustomElement lets you tell the compiler which tags are custom elements, preventing Vue from trying to resolve them as Vue components.

Angular requires the CUSTOM_ELEMENTS_SCHEMA in the module or component declaration to allow unknown element tags without errors. It binds to properties by default when using [property]="value" syntax and to attributes when using [attr.name]="value". Angular's change detection integrates smoothly because it calls property setters directly, triggering any reactive logic inside the web component.

Event Handling

Web components dispatch standard DOM events via this.dispatchEvent(new CustomEvent('change', { detail, bubbles: true, composed: true })). The composed: true flag is essential — without it, the event does not cross shadow DOM boundaries and frameworks cannot listen for it.

React historically did not support custom events on custom elements in JSX. Writing <my-input onChange={handler}> would listen for the native change event, but <my-input onCustomChange={handler}> would do nothing because React's synthetic event system only knows about standard DOM events. React 19 resolves this by detecting on + PascalCase props and adding native event listeners. For older React versions, you must use ref.current.addEventListener('custom-change', handler) in an effect.

Vue and Angular handle custom events natively. Vue's @my-event="handler" and Angular's (my-event)="handler()" both attach standard DOM event listeners, so any CustomEvent dispatched by the web component is caught without special handling.

Slot Distribution

Slots in web components are a DOM-level concept: children of the host element are projected into <slot> elements inside the shadow DOM. This maps directly to Vue's <slot> concept, and Vue handles it transparently — you just put content inside the custom element tag.

React does not have a native slot concept. Children passed to a custom element in JSX become the element's light DOM children, which naturally distribute into the shadow DOM's default slot. Named slots require adding slot="name" attributes to children, which works in JSX: <span slot="header">Title</span>.

Angular's ng-content is conceptually similar but mechanically different from DOM slots. When using web components, Angular places child elements as light DOM children of the custom element, which distribute into shadow DOM slots correctly. But Angular's own content projection system is not aware of shadow DOM slot reassignment events.

Wrapper Patterns

For teams using web components heavily within a framework, wrapper components smooth the integration. A React wrapper component uses useRef and useEffect to set properties and subscribe to events, exposing them as React props and callbacks. Libraries like @lit/react automate this pattern: you pass the web component class and a mapping of events to React prop names, and it generates a typed React wrapper component.

The wrapper approach preserves framework idioms — React developers get familiar props and callbacks, Vue developers get v-model support, Angular developers get typed inputs and outputs — while the underlying implementation remains a platform-standard web component usable anywhere.

Gotchas

  • React < 19 serializes non-string props to attributes — passing an object or array as a JSX prop to a custom element produces "[object Object]" as the attribute value. Use refs or upgrade to React 19.
  • composed: false events are invisible to parent frameworks — if a custom event does not set composed: true, it stops at the shadow root boundary and the framework never receives it.
  • SSR hydration mismatches — custom elements may not be defined when the server renders HTML. The browser shows an unstyled HTMLElement until the definition loads, causing layout shift and potential hydration errors with frameworks like Next.js.
  • v-model does not work automatically — Vue's v-model expects a component to emit update:modelValue. Web components emitting standard input or change events need a custom v-model configuration or a wrapper.
  • Type safety across boundaries is manual — TypeScript cannot infer the prop types or event types of a custom element from JSX or template syntax without explicit type augmentation of HTMLElementTagNameMap or framework-specific declaration files.