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.