Projecting Angular Content into Web Components
An Angular template that references a custom element fails to compile with 'my-element' is not a known element, and even after that is resolved, [value] and (my-event) bindings behave in ways that surprise developers coming from @Input/@Output. Angular’s compiler validates every tag and binding against its known component metadata; a framework-agnostic custom element has none, so it must be explicitly admitted and bound through Angular’s property and event syntax. This page walks the full path from compiler error to a form-integrated control.
This is the Angular counterpart within Framework Integration & Adapters: Angular’s strong template typing is a feature, and integrating custom elements means opting specific tags out of it deliberately.
Minimal reproducible example
A standards-compliant rating element that exposes a value property and dispatches a composed rating-change event:
class StarRating extends HTMLElement {
#value = 0;
get value() { return this.#value; }
set value(v) { this.#value = Number(v) || 0; this.#render(); }
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.#render();
this.shadowRoot.addEventListener('click', (e) => {
if (e.target.dataset.star) {
this.value = Number(e.target.dataset.star);
this.dispatchEvent(new CustomEvent('rating-change', {
detail: { value: this.#value }, bubbles: true, composed: true
}));
}
});
}
#render() {
this.shadowRoot.innerHTML = [1,2,3,4,5]
.map(n => `<button data-star="${n}">${n <= this.#value ? '[x]' : '[ ]'}</button>`).join('');
}
}
customElements.define('star-rating', StarRating);
The natural Angular template throws at compile time:
<!-- ERROR: 'star-rating' is not a known element -->
<star-rating [value]="rating" (rating-change)="onRate($event)"></star-rating>
The Angular AOT compiler rejects both the unknown tag and the [value] binding (it cannot find a value @Input on a known component). The build fails before the element ever runs.
Root-cause analysis
Angular’s template compiler treats every element and binding as something it must verify. By default it resolves each tag against the registered components and known HTML elements; an unrecognized hyphenated tag matches neither, so the compiler raises NG8001: 'star-rating' is not a known element. Likewise, [value] is interpreted as a binding to a component or directive input named value, and finding none, the compiler errors rather than silently emitting a DOM property write.
This strictness exists because Angular’s template type-checking catches typos and contract drift at build time. Custom elements have no Angular metadata to check against, so Angular needs an explicit signal that a given tag is outside its component system. That signal is CUSTOM_ELEMENTS_SCHEMA. Adding it to a component’s (or NgModule’s) schemas relaxes the compiler: unknown elements are allowed, and unknown property bindings on them are emitted as DOM operations instead of input bindings.
Crucially, once the schema is in place, Angular’s binding syntax is unambiguous about the channel — unlike Vue’s heuristic. [value]="x" always writes the DOM property value. [attr.value]="x" always writes the attribute. (rating-change)="h($event)" always calls addEventListener('rating-change', ...) and passes the native event as $event. There is no guessing. This precision is what makes Angular score 100% on the custom-elements compatibility benchmark referenced in Framework Integration & Adapters.
Production-safe fix
Step 1 — Register CUSTOM_ELEMENTS_SCHEMA
For a standalone component (the modern default), add the schema to its decorator:
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-review-form',
standalone: true,
templateUrl: './review-form.component.html',
schemas: [CUSTOM_ELEMENTS_SCHEMA] // admits all custom elements in this template
})
export class ReviewFormComponent {
rating = 3;
onRate(event: CustomEvent<{ value: number }>) {
this.rating = event.detail.value;
}
}
For an NgModule-based app, add it to the module’s schemas array instead. Ensure the element’s customElements.define() runs before the Angular app bootstraps (import the element bundle in main.ts).
Step 2 — Bind properties and events explicitly
<star-rating
[value]="rating"
[attr.aria-label]="'Rating: ' + rating"
(rating-change)="onRate($event)">
</star-rating>
[value] writes the DOM property — correct for the numeric value and for any object/array data, which has no attribute representation. Use [attr.x] only for genuine attributes (ARIA, reflected string state). The (rating-change) binding receives the native CustomEvent, so read $event.detail.
Step 3 — Bridge into Angular forms with ControlValueAccessor
To make the element work with ngModel/formControlName, wrap it in a directive that implements ControlValueAccessor:
import { Directive, ElementRef, HostListener, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Directive({
selector: 'star-rating',
standalone: true,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingValueAccessor),
multi: true
}]
})
export class StarRatingValueAccessor implements ControlValueAccessor {
private onChange: (v: number) => void = () => {};
private onTouched: () => void = () => {};
constructor(private el: ElementRef<HTMLElement & { value: number }>) {}
writeValue(value: number): void { this.el.nativeElement.value = value ?? 0; }
registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
@HostListener('rating-change', ['$event'])
handleChange(e: CustomEvent<{ value: number }>): void {
this.onChange(e.detail.value);
this.onTouched();
}
}
Now <star-rating formControlName="score"></star-rating> participates in reactive forms: writeValue pushes the model value to the property, and the rating-change listener feeds user input back into the Angular control.
Verification
// In a component spec (TestBed) or via DevTools console:
const el = document.querySelector('star-rating') as HTMLElement & { value: number };
// 1. Property binding reached the element, not an attribute.
console.log(el.value); // 3 (number, not a string)
console.log(el.getAttribute('value')); // null (correctly NOT reflected)
// 2. Event binding is wired: dispatch and confirm the form control updates.
el.dispatchEvent(new CustomEvent('rating-change', { detail: { value: 5 }, bubbles: true }));
// reactive form: expect(form.get('score')!.value).toBe(5);
In Angular DevTools, the component’s rating state should update to 5 after the dispatch. If the compile error persists, the schema is on the wrong component or module. If [value] does nothing, confirm the element actually exposes a value property setter (Angular wrote el.value, but the element ignored it).
When to use which approach
| Need | Approach | Notes |
|---|---|---|
| Use a custom element at all | CUSTOM_ELEMENTS_SCHEMA |
Per standalone component or module |
| Pass a string / ARIA | [attr.name]="x" |
Real attribute write |
| Pass numbers / objects / arrays | [prop]="x" |
DOM property; no attribute exists for rich data |
| Receive a custom event | (my-event)="h($event)" |
$event is the native CustomEvent |
| Template-driven / reactive forms | ControlValueAccessor directive |
Bridges value + change event to the control |
| Avoid | Treating [value] as guaranteed input |
It is a DOM property write; the element must implement it |
Add CUSTOM_ELEMENTS_SCHEMA at the narrowest scope that needs it rather than the root module, so the rest of the app keeps full template type-checking. Reach for the ControlValueAccessor bridge only when the element must live inside Angular forms; for display-only or event-only elements, plain property and event bindings are sufficient. Contract-testing the event payloads and form behavior belongs in Distribution, Testing & Tooling.
Frequently Asked Questions
Why does Angular reject my custom element tag?
Because the template compiler treats unknown elements as errors by default. Registering CUSTOM_ELEMENTS_SCHEMA for the module or component accepts them — and simultaneously stops checking every other unknown element and attribute, which is the tradeoff generated wrapper directives exist to avoid.
Does ng-content project into a custom element's slots?
No. ng-content is Angular’s own mechanism, resolved before the browser sees anything. Native projection needs a real child element carrying a slot attribute; Angular passes that attribute through unchanged.
Do I need NgZone.run for custom events?
Usually not: zone patching wraps addEventListener, so a listener bound with Angular’s own (event) syntax already runs inside the zone and triggers change detection. A listener added manually outside Angular’s binding does need it, or a signal update.
How do object inputs reach the component?
Angular’s property binding assigns a DOM property when one exists, so [items]="rows" arrives as an array — provided the component declares an items property. An attribute binding with attr.items would stringify it.
Should I generate wrapper directives instead of using the schema?
For a library, yes. Generated directives declare real inputs and outputs, so the template compiler checks them and consumers get autocomplete — whereas CUSTOM_ELEMENTS_SCHEMA buys acceptance by disabling checking for every unknown element in the module.
Does Angular's structural directive reuse break projection?
It can. *ngIf and *ngFor move real DOM nodes, and a wrapper element introduced by the directive stops those nodes being direct children of the host — which is enough for named assignment to skip them entirely.
Does Angular's OnPush strategy affect custom element updates?
Only for bindings Angular owns. A property bound with [value] updates when change detection runs, so under OnPush an update triggered outside Angular needs a signal write or an explicit mark — while anything the component changes internally is invisible to change detection either way.
How should a form-integrated custom element work with Angular forms?
Through a ControlValueAccessor that writes the component’s value property and listens for its change event. Because a form-associated custom element already participates in native validation, the accessor stays thin — it bridges Angular’s model to the DOM property rather than reimplementing the control’s behaviour.
Do generated directives replace the schema entirely?
For the components they cover, yes: real inputs and outputs mean the compiler checks the template again, so the schema is no longer needed for those tags. Keep the schema only where a genuinely unknown element must be tolerated.
Related
- Framework Integration & Adapters — the parent topic on consuming custom elements in frameworks.
- Bridging Custom Events to React — the equivalent event-binding model in React.
- Mapping Named Slots in Vue — content projection and binding in Vue.
- Event Composition & Bubbling — composed events that Angular’s
(event)syntax can catch. - Core Architecture & Lifecycle Management — the grandparent section.