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.
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.