Framework Integration & Adapters
Consuming framework-agnostic custom elements inside React, Vue, and Angular exposes a fault line between the native DOM contract — attributes, properties, and CustomEvent dispatch — and each framework’s templating layer, which substitutes its own abstractions over that contract. Getting integration right means knowing exactly how a given framework binds a value to a node and listens for an event, because the wrong assumption silently routes data to the wrong channel.
This guide sits within Core Architecture & Lifecycle Management and treats the three dominant frameworks as host environments for the same underlying element. A custom element authored against the HTML and DOM standards is a black box to the framework: it knows nothing of JSX, Vue templates, or Angular’s compiler. The framework, in turn, must decide for every binding whether to call setAttribute(), assign a JavaScript property, or attach a DOM event listener. Those three decisions are the entire surface area of framework interop, and every compatibility bug traces back to one of them.
Concept definition: the custom-elements-everywhere contract
The reference benchmark for this domain is the Custom Elements Everywhere project, which runs a fixed suite of tests against every major framework: can it set primitive attributes, set rich-data properties, handle declaratively named events, and handle DOM events with non-standard names? The results define what “integration” means in practice. A framework that scores 100% — Vue 3, Angular, Svelte, and (as of version 19) React — passes data through the native channels the component author expects. A framework that scores lower forces consumers into escape hatches.
The HTML standard draws a hard line that the benchmark probes: an attribute is a string token serialized in markup and read with getAttribute(), while a property is a live, typed JavaScript value on the element instance. A custom element may reflect between the two, but reflection only covers serializable primitives. Object and array data — a items array, a config object — has no string representation, so it can only arrive through a property assignment. The full reflection contract for how these two channels stay aligned is covered in Attribute Reflection & Property Sync. When a framework forces every binding through setAttribute(), rich data either coerces to "[object Object]" or is dropped entirely.
Events are the third channel. Custom elements communicate outward by dispatching a composed CustomEvent so it crosses the shadow boundary. A framework integrates cleanly only if it can attach a listener for an arbitrarily named event — cart-updated, value-changed — declaratively in its template. Frameworks that only understand a fixed allowlist of DOM events (the classic React limitation) cannot bind these without imperative code.
The benchmark therefore measures four orthogonal capabilities, and a framework can fail any subset independently: setting primitive attributes (every framework passes), setting properties for rich data (React <19 fails), handling declaratively named events such as foo (React <19 fails), and handling DOM events with names that are not valid JavaScript identifiers, such as foo-bar (React <19 fails). Vue 3 and Angular pass all four. Keeping these capabilities distinct in your mental model is what lets you diagnose an integration bug in seconds: a missing handler is an event failure, a stringified object is a property failure, and a “not a known element” error is a configuration failure — three different channels, three different fixes.
Browser engine integration points
All three frameworks ultimately produce real DOM nodes, and the browser upgrades a <my-element> to its registered class the moment the parser or document.createElement encounters it — provided the definition has run. This creates a timing dependency that frameworks handle differently. React and Vue mutate the DOM during their commit/patch phase; Angular does so during change detection. In every case the element may be inserted before customElements.define() has executed, in which case the browser holds it as an undefined (un-upgraded) element until definition, then runs the upgrade and fires connectedCallback.
The practical consequence is that property assignments made by a framework before upgrade are stored as plain own-properties on the instance and only become meaningful if the element class uses a property-upgrade pattern (re-reading and deleting pre-upgrade own-properties in connectedCallback). Frameworks do not do this for you. A component that does not guard for pre-upgrade properties will lose data set during a fast initial render. Use customElements.whenDefined() to gate framework rendering on availability when load order is not guaranteed.
The thread model matters too. Custom-element upgrade, connectedCallback, and CustomEvent dispatch all run synchronously on the main thread, interleaved with the framework’s own render work. React’s commit phase, Vue’s flush, and Angular’s change-detection tick each mutate the DOM and may, in the same synchronous turn, trigger the element’s lifecycle callbacks. If an element dispatches an event during connectedCallback while a framework is mid-render, the framework receives a state update inside its own render pass — a classic source of “cannot update during render” warnings. Defer outbound events to a microtask (queueMicrotask) or the next frame when they are a side effect of mounting, so the dispatch lands after the framework’s current pass settles.
Core API surface across the three frameworks
The integration surface reduces to a small configuration table. Each framework needs to be told that an unrecognized tag is a custom element (so it does not warn or attempt to resolve it as a component), and each exposes syntax to force a binding onto a specific channel.
| Framework | Tell it the tag is custom | Force property binding | Force attribute binding | Listen to my-event |
|---|---|---|---|---|
| React 19+ | (automatic) | prop={value} (auto-detected) |
attr="str" |
onMyEvent={fn} |
| React <19 | (automatic, attrs only) | ref + imperative assign |
attr="str" (default) |
ref + addEventListener |
| Vue 3 | compilerOptions.isCustomElement |
.prop="value" or :prop |
:attr.attr="str" |
@my-event="fn" |
| Angular | CUSTOM_ELEMENTS_SCHEMA |
[prop]="value" |
[attr.name]="str" |
(my-event)="fn($event)" |
In Vue, app.config.compilerOptions.isCustomElement = (tag) => tag.includes('-') (or a build-time vue() plugin option) stops Vue from treating <my-element> as a Vue component and suppresses the “Failed to resolve component” warning. Vue 3 then applies a per-binding heuristic: it sets a property if the key exists on the element’s DOM instance, otherwise it sets an attribute — and the .prop and .attr modifiers override the heuristic explicitly. Named-slot mapping has its own subtleties, covered in Mapping Named Slots in Vue.
In Angular, adding CUSTOM_ELEMENTS_SCHEMA to a component’s (or module’s) schemas array tells the Angular compiler to permit unknown elements and unknown property bindings instead of throwing a template error. Angular’s binding syntax is unambiguous by design: [items] always sets a property, [attr.foo] always sets an attribute, and (my-event) always attaches a native addEventListener. Full details in Projecting Angular Content into Web Components.
Production implementation pattern: a portable wrapper element
The most robust integration strategy is to keep the element framework-agnostic and let each app bind to it natively. The element below exposes both a reflected string attribute and a rich-data property, and dispatches a composed event — the three channels every framework must handle.
class DataPicker extends HTMLElement {
static observedAttributes = ['label'];
#items = [];
#internalsApplied = false;
connectedCallback() {
// Property-upgrade guard: recover values a framework set before upgrade.
for (const prop of ['items']) {
if (Object.prototype.hasOwnProperty.call(this, prop)) {
const value = this[prop];
delete this[prop];
this[prop] = value;
}
}
this.#render();
}
attributeChangedCallback(name, _old, value) {
if (name === 'label') this.#render();
}
// Rich data arrives ONLY through this property — never an attribute.
get items() { return this.#items; }
set items(next) {
this.#items = Array.isArray(next) ? next : [];
this.#render();
}
#render() {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
const label = this.getAttribute('label') ?? 'Pick one';
this.shadowRoot.innerHTML = `<fieldset><legend>${label}</legend></fieldset>`;
const set = this.shadowRoot.querySelector('fieldset');
for (const item of this.#items) {
const btn = document.createElement('button');
btn.textContent = item.name;
btn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('item-selected', {
detail: { id: item.id },
bubbles: true,
composed: true
}));
});
set.append(btn);
}
}
}
customElements.define('data-picker', DataPicker);
Consuming this in React 19 is fully declarative: <data-picker label="City" items={cities} onItemSelected={handle} />. In Vue 3: <data-picker label="City" :items="cities" @item-selected="handle" />. In Angular: <data-picker label="City" [items]="cities" (item-selected)="handle($event)"></data-picker>. Each framework routes label to an attribute, items to the property, and the event to a native listener.
Common failure modes & debugging steps
-
Rich data renders as
[object Object]. Root cause: the framework setitemsas an attribute, stringifying the array. This is the classic React <19 failure — React passed everything throughsetAttribute(). Concrete fix: upgrade to React 19, or assign the property imperatively via arefinuseEffect. Confirm in DevTools that$0.itemsis an array, not that$0.getAttribute('items')holds a string. -
A custom event handler never fires. Root cause: React <19 only recognizes its synthetic-event allowlist (
onClick,onChange, …) and ignoresonItemSelectedentirely; it neither attaches a listener nor warns. The full reproduction and three fixes are in Bridging Custom Events to React. Concrete fix:ref.current.addEventListener('item-selected', handler)insideuseEffectwith a cleanup, or React 19’s nativeonItemSelected. -
Vue logs “Failed to resolve component: my-element”. Root cause:
isCustomElementwas not configured, so Vue tried to resolve the tag as a Vue component. Concrete fix: setapp.config.compilerOptions.isCustomElementat runtime, or the equivalent option in the build-tool Vue plugin so the compiler skips resolution at build time. -
Angular template throws
'my-element' is not a known element. Root cause: the schema was not declared, so the Angular compiler rejects the unknown tag and any[unknownProp]binding. Concrete fix: addCUSTOM_ELEMENTS_SCHEMAto the standalone component’sschemas(or theNgModule). Verify the error disappears and that[items]reaches the property.
Framework interop and SSR differences
React 19 was the inflection point. Before it, the React DOM renderer treated unknown props on custom elements as attributes and had no mechanism for declarative custom-event listeners, so the ecosystem relied on wrapper generators (Lit’s @lit/react, Stencil’s output target) that produced a React component bridging properties and events via refs. React 19 changed the renderer: a prop whose name matches a property on the element instance is assigned as a property, and on-prefixed camelCase handlers are attached as listeners for the lowercased event name. This brought React’s Custom Elements Everywhere score to 100%.
Vue 3 and Angular have scored 100% for years because both expose explicit property and event syntax. The remaining interop concern is server-side rendering: React, Vue, and Angular all serialize an element’s light DOM on the server but cannot run the element’s client-side connectedCallback to populate its shadow root. Pairing these elements with Declarative Shadow DOM and a hydration-aware build is the domain of Distribution, Testing & Tooling; without it, custom elements render empty until client-side definition runs.
Performance, memory, and bundle implications
The imperative ref + addEventListener workaround for React <19 is the most common leak vector in this space: a listener attached in useEffect without a returned cleanup function survives re-renders and detaches, accumulating duplicate handlers. Always return a cleanup that calls removeEventListener with the same reference, or attach with an AbortController and abort in cleanup. Wrapper components generated by tooling add a small per-component bundle cost but eliminate this entire failure class by centralizing teardown.
On the property channel, frameworks that re-assign object properties on every render can defeat a component’s internal dirty-checking if the component does not compare references. Memoize array and object props (React useMemo, Vue computed, Angular signals or OnPush) so the element only re-renders on genuine change, and so property setters are not called with a fresh-but-equal object each cycle.
Bundle cost divides into two parts: the element library itself, and the per-framework glue. The element library should ship as side-effect-free ESM so consumers tree-shake unused components — the same packaging discipline covered in Distribution, Testing & Tooling. The glue is where frameworks differ. React’s generated wrappers add a thin component per element (a few hundred bytes each) but eliminate per-call-site ref boilerplate; Vue and Angular need no glue at all beyond the one-time isCustomElement or schema declaration, so their integration cost is effectively zero bytes. When choosing between hand-written ref effects and generated wrappers for React, weigh the duplicated teardown logic at every call site against the wrapper’s fixed per-component overhead; past a handful of usages, the wrapper wins on both bundle size and correctness.
Browser compatibility & polyfill strategy
Custom elements, shadow DOM, and CustomEvent are supported in all current evergreen browsers (Chrome/Edge 67+, Firefox 63+, Safari 10.1+), so no element-level polyfill is needed for modern targets. The framework-specific support floors are what matter: React’s native custom-element support requires React 19.0+ (released December 2024); earlier versions need the workarounds above. Vue 3’s isCustomElement has existed since the 3.0 release. Angular’s CUSTOM_ELEMENTS_SCHEMA predates the Ivy compiler and works in every supported Angular version. For legacy-browser strategy and progressive enhancement, see the polyfill guidance under Distribution, Testing & Tooling.
Related
- Bridging Custom Events to React — why
onMyEventsilently fails before React 19 and the three fixes. - Mapping Named Slots in Vue — projecting content into
<slot name>from Vue templates. - Projecting Angular Content into Web Components — schema registration, binding syntax, and form control bridging.
- Event Composition & Bubbling — composing events that frameworks can actually receive.
- Core Architecture & Lifecycle Management — the parent section on platform-native component architecture.