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.

Angular bindings routed to a custom element CUSTOM_ELEMENTS_SCHEMA admits the tag, then each Angular binding syntax routes to a distinct DOM channel. CUSTOM_ELEMENTS_SCHEMA admits <star-rating> into the template [value]="rating" DOM property write [attr.label]="x" setAttribute call (rating-change)="h()" addEventListener el.value = rating getAttribute('label') $event.detail.value Angular's syntax names the channel explicitly - no heuristic guessing.

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.

Angular's content projection compared with native slot assignment ng-content selects projected nodes inside an Angular component, while a native slot attribute is what a custom element's shadow tree assigns, and the schema registration is what stops the compiler rejecting the tag. Two projection systems, meeting at one element Angular ng-content <ng-content select="[slot=title]"> resolved by Angular at compile time inside Angular components only never involved in a custom element's shadow tree native slot attribute <h3 slot="title"> passed straight through assignment happens in the browser needs CUSTOM_ELEMENTS_SCHEMA to compile which also disables checking for every unknown tag Angular binding syntax mapped to the DOM channel it writes Square brackets write a property, attr dot writes an attribute, and parentheses register a native listener, so the channel is explicit rather than inferred. Angular names the channel explicitly — no heuristics [value]="rating" element.value = rating — a property write [attr.label]="text" setAttribute('label', text) — a string (rating-change)="onRate($event)" addEventListener, $event is the native event Rich values therefore arrive intact through the first form and stringify through the second.

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.