Implementing Cross-Framework Web Components (React/Vue/Angular)
The main promise of Web Components is "write once, use everywhere." Fulfilling this promise requires solving a number of non-trivial problems: typing in each framework, event handling, two-way binding, SSR compatibility.
Problems using Web Components in frameworks
React (before version 19) does not pass objects and arrays through attributes. React's event model doesn't automatically pick up custom DOM events. With React 19, the situation improved, but requires checking.
Angular requires CUSTOM_ELEMENTS_SCHEMA to work with non-standard tags.
Vue — most friendly to Web Components framework, handles most cases natively.
SSR — customElements.define doesn't exist in Node.js. Components can't be rendered on the server without special solutions.
React: correct integration
// Problem: React passes objects as string "[object Object]"
// Solution: ref + useEffect to set properties directly
import { useRef, useEffect, forwardRef } from 'react'
// Wrapper component for Web Component with object props
interface DataTableProps {
columns: Column[]
rows: Row[]
onRowSelect?: (row: Row) => void
onSort?: (column: string, direction: 'asc' | 'desc') => void
}
export const DataTable = forwardRef<HTMLElement, DataTableProps>(
({ columns, rows, onRowSelect, onSort }, forwardedRef) => {
const ref = useRef<HTMLElement>(null)
// Forward the ref
useEffect(() => {
if (typeof forwardedRef === 'function') forwardedRef(ref.current)
else if (forwardedRef) forwardedRef.current = ref.current
}, [forwardedRef])
// Pass objects through properties, not attributes
useEffect(() => {
if (ref.current) {
(ref.current as any).columns = columns
}
}, [columns])
useEffect(() => {
if (ref.current) {
(ref.current as any).rows = rows
}
}, [rows])
// Custom events
useEffect(() => {
const el = ref.current
if (!el) return
const handleRowSelect = (e: Event) => {
onRowSelect?.((e as CustomEvent).detail)
}
const handleSort = (e: Event) => {
const { column, direction } = (e as CustomEvent).detail
onSort?.(column, direction)
}
el.addEventListener('row-select', handleRowSelect)
el.addEventListener('sort', handleSort)
return () => {
el.removeEventListener('row-select', handleRowSelect)
el.removeEventListener('sort', handleSort)
}
}, [onRowSelect, onSort])
return <data-table ref={ref} />
}
)
React 19: improved support
// React 19 natively supports passing objects to Web Components
// and subscribing to custom events via on* props
// Typing for new React 19 behavior
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'data-table': {
ref?: React.Ref<HTMLElement>
columns?: Column[] // React 19: object passed directly
rows?: Row[]
'on-row-select'?: (e: CustomEvent<Row>) => void
onRowSelect?: (e: CustomEvent<Row>) => void // React 19
}
}
}
}
Angular: schema and wrappers
// app.module.ts — allow unknown elements
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], // ← required
bootstrap: [AppComponent],
})
export class AppModule {}
// Standalone component — without NgModule
@Component({
selector: 'app-page',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<ui-button
variant="primary"
[disabled]="isLoading"
(uiClick)="handleClick($event)"
>
Save
</ui-button>
`,
})
export class PageComponent {
isLoading = false
handleClick(e: CustomEvent) {
this.isLoading = true
// ...
}
}
Angular directive wrapper for two-way binding:
// Directive for <ui-input> with [(ngModel)] support
import { Directive, forwardRef, HostListener, ElementRef } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
@Directive({
selector: 'ui-input[formControlName], ui-input[ngModel]',
standalone: true,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => UiInputValueAccessor),
multi: true,
}],
})
export class UiInputValueAccessor implements ControlValueAccessor {
private onChange: (value: string) => void = () => {}
private onTouched: () => void = () => {}
constructor(private el: ElementRef) {}
@HostListener('sl-input', ['$event.target.value'])
@HostListener('sl-change', ['$event.target.value'])
onInput(value: string) {
this.onChange(value)
}
@HostListener('sl-blur')
onBlur() { this.onTouched() }
writeValue(value: string) {
this.el.nativeElement.value = value ?? ''
}
registerOnChange(fn: (v: string) => void) { this.onChange = fn }
registerOnTouched(fn: () => void) { this.onTouched = fn }
setDisabledState(disabled: boolean) {
this.el.nativeElement.disabled = disabled
}
}
Vue: native support
Vue 3 works with Web Components with minimal setup:
// vite.config.ts — don't parse custom tags as Vue components
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Elements with '-' in name — Web Components
isCustomElement: (tag) => tag.includes('-'),
},
},
}),
],
})
<template>
<!-- Props passed as attributes for primitives -->
<ui-button variant="primary" :disabled="isLoading" @ui-click="handleClick">
Send
</ui-button>
<!-- Objects via .prop modifier -->
<data-table
.columns="columns"
.rows="rows"
@row-select="handleRowSelect"
/>
<!-- v-model for custom input -->
<ui-input v-model="formValue" label="Email" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import '@company/ui-core/components/button.js'
import '@company/ui-core/components/input.js'
import '@company/ui-core/components/data-table.js'
const isLoading = ref(false)
const formValue = ref('')
// v-model for Web Component — needs defineCustomElement or manually:
// v-model compiles to :modelValue + @update:modelValue
// Web Component should emit 'update:modelValue' event
</script>
Svelte
<script lang="ts">
import '@company/ui-core/components/button.js'
let loading = false
function handleClick(e: CustomEvent) {
loading = true
// ...
}
</script>
<!-- Svelte: on:событие for custom events -->
<ui-button
variant="primary"
disabled={loading}
on:ui-click={handleClick}
>
Send
</ui-button>
SSR: solving server-side rendering
On the server there's no customElements, HTMLElement, window. Solutions:
// 1. Lazy import only on client (Next.js)
// components/UiButton.tsx
import dynamic from 'next/dynamic'
const UiButtonClient = dynamic(
() => import('./UiButtonClient').then(m => m.UiButtonClient),
{ ssr: false }
)
export function UiButton(props: ButtonProps) {
return <UiButtonClient {...props} />
}
// 2. Polyfill for SSR (experimental)
// @lit-labs/ssr — server render Lit components
import { renderToString } from '@lit-labs/ssr'
import { html } from 'lit'
const result = renderToString(html`
<ui-button variant="primary">Click</ui-button>
`)
// Returns declarative shadow DOM markup
<!-- Declarative Shadow DOM — SSR-compatible -->
<ui-button>
<template shadowrootmode="open">
<style>/* ... */</style>
<button class="btn btn--primary">Click</button>
</template>
Click
</ui-button>
Universal wrappers: @lit/react
Official solution from the Lit team for React integration:
import { createComponent } from '@lit/react'
import React from 'react'
import { UiButton } from '@company/ui-core'
export const Button = createComponent({
tagName: 'ui-button',
elementClass: UiButton,
react: React,
events: {
onUiClick: 'ui-click',
onUiFocus: 'ui-focus',
onUiBlur: 'ui-blur',
},
})
// Now Button works as a React component with typing
function App() {
return (
<Button
variant="primary"
onUiClick={(e) => console.log(e.detail)}
>
Click
</Button>
)
}
Compatibility checklist
Before publishing cross-framework library:
- All custom events use
composed: trueandbubbles: true - Object properties don't mirror to attributes (
reflect: falsefor objects) - Component works correctly with
disabledviaElementInternals - No direct access to
window,documentinconstructor— only inconnectedCallback - Types exported for each framework
- Added
custom-elements.json(CEM) for IDE autocomplete
Timeline
Integrating existing Web Components library into one framework with types and wrappers — 3–5 days. Supporting React + Vue + Angular + SSR with full set of types and documentation — 3–4 weeks.







