skip to content

With Angular Elements, how do a component's inputs and outputs map to the custom element's attributes, properties and DOM events?

level: middleimportance: should knowfreq 33%

answer

  1. strings in, objects by property
  2. alias, then dash-case
  3. property keeps the class field name
  4. outputs become CustomEvent, payload in detail
  5. dispatched on the host, no bubbling

basics

~20 s

Each input becomes a dash-case attribute (from its alias or name) and an element property; attribute strings pass through the input's transform. Each output is dispatched on the element as a CustomEvent named after it, with the value in event.detail.

solid answer

~40 s

`createCustomElement` reads the component's inputs and outputs. Every input gets an **observed attribute** whose name is the input's public name (its alias if it has one) converted to dash-case - `maxStars` becomes `max-stars` - and a **property** on the element named after the class field, whose setter calls `setInput` on the component. Attribute values arrive as strings, so the input's `transform` (`numberAttribute`, `booleanAttribute`) does the conversion; arrays and objects must be set through the property. Since v21 reading a signal input's property returns its value, not the signal. Every output is re-dispatched on the host element as a `CustomEvent` whose type is the output's name or alias - not dash-cased - with the payload in `event.detail`; the event is created without `bubbles`, so the page listens on the element itself.

code

ts · 17 lines
ts
import { Component, booleanAttribute, input, numberAttribute, output } from '@angular/core';

@Component({
  selector: 'app-rating',
  template: `
    @for (label of labels(); track $index) {
      <button type="button" (click)="rated.emit($index + 1)">{{ label }}</button>
    }
    @if (!compact()) { <small>max {{ maxStars() }}</small> }
  `,
})
export class Rating {
  readonly maxStars = input(5, { transform: numberAttribute });
  readonly compact = input(false, { transform: booleanAttribute });
  readonly labels = input<string[]>([]);
  readonly rated = output<number>();
}

go deeper

for a junior

Recall the three channels: dash-case attributes and properties for inputs, a CustomEvent with detail for each output.

for a middle

Explain the naming rules (alias drives the attribute, field drives the property), why transforms matter for string attributes, and why the event does not bubble.

for a senior

Design a widget's public contract for a page you do not own: which inputs are safe as attributes, which need properties, and how consumers listen reliably.

for a principal

Treat the element's attributes, properties and event names as a public API with versioning costs, because renaming an alias or output breaks every embedding page.

## The contract a custom element exposes A custom element talks to its page through three channels: **attributes** (strings in markup), **properties** (any JavaScript value, set from script) and **DOM events** (going out). Angular components talk through **inputs** and **outputs**. `createCustomElement()` from `@angular/elements` builds the bridge by reflecting over the component (`reflectComponentType`) and generating the element's API from what it finds. | Component side | Element side | Name rule | |---|---|---| | input `maxStars` | attribute `max-stars` | input's public name (alias if set), camelCase to dash-case | | input `maxStars` | property `el.maxStars` | the class field name, even when an alias exists | | input with alias `'barbar'` on field `barBar` | attribute `barbar`, property `el.barBar` | alias drives the attribute, field drives the property | | output `rated` | `CustomEvent` of type `rated` | output name or alias, used verbatim (no dash-case) | ## Inputs as attributes The generated class has a static `observedAttributes` list, built by dash-casing each input's template name. The browser calls the element's `attributeChangedCallback` whenever one of those attributes is set or changed, and Angular forwards the new value to the component's input. Key consequences: - **Values are strings.** `max-stars="10"` delivers `"10"`, not `10`, and removing the attribute delivers `null`. The input's **transform** runs on the way in, because the value goes through the component's `setInput`, so `input(5, { transform: numberAttribute })` receives a number and `input(false, { transform: booleanAttribute })` turns a bare `compact` attribute into `true`. - **Initial markup counts.** Attributes present when the element is upgraded are delivered too, cached until the component exists, then applied before its first change detection. - **HTML is case-insensitive.** Writing `maxStars="10"` in markup produces an attribute named `maxstars`, which is not observed, so nothing happens. The dash-case name is the only attribute that maps. ## Inputs as properties For every input the class defines a getter and setter on its prototype, named after the **class field** (`propName`): - The setter calls the strategy, which calls `componentRef.setInput(...)`; if the component does not exist yet the value is cached and applied on connect. - This is how you pass **rich data** - arrays, objects, functions - that cannot survive a trip through a string attribute. - The getter returns the component's current input value. **Since Angular v21** a signal input's getter returns the value itself (`el.maxStars`), matching decorator inputs; before v21 it returned the `InputSignal`, so code wrote `el.maxStars()`. - A property set on the node **before** the element was defined (for example by a page script that ran before the widget bundle) is picked up when the element is upgraded and re-applied through the component's input. Because `setInput` marks the view dirty and the element notifies Angular's change-detection scheduler, an input change re-renders the component without zone.js and under the `OnPush` default of v22. ## Outputs as DOM events On connect Angular subscribes to every output (`output()` or `@Output` with `EventEmitter`) and, for each emission, does the equivalent of `element.dispatchEvent(new CustomEvent(name, { detail: value }))`: 1. The **event type** is the output's public name used verbatim - `valueChanged` stays `valueChanged`, and an alias such as `output({ alias: 'myClick' })` becomes `myClick`. 2. The **payload** is on `event.detail`. 3. The event is dispatched on the **host element** and created **without `bubbles`**, so a listener on `document` or a container does not see it; attach it to the element. Outputs get no property on the element: there is no `el.rated.subscribe()` from the page. The event is the only channel. ## Typing the element in TypeScript `@angular/elements` exports `NgElement` and `WithProperties<P>`. A cast such as `document.createElement('kj-rating') as NgElement & WithProperties<{ maxStars: number }>` gives type-checked property access, and augmenting `HTMLElementTagNameMap` once makes `querySelector('kj-rating')` infer that type everywhere. ## What trips people up - Expecting `el.addEventListener('rated-change', ...)` or a dash-cased event name: events keep the output's exact name. - Passing JSON in an attribute and expecting an object: nothing parses it unless your own transform does. - Listening on `document` for an event that never bubbles. - Writing `el.maxStars()` in v21+ code, which now calls a number.

  • An input has an alias: @Input('barbar') barBar. What attribute and property does the custom element expose?
    The attribute comes from the public (aliased) name, dash-cased: `barbar`, which has no capitals so stays as is. The property keeps the class field name, `el.barBar`. Aliases therefore change the markup attribute but not the script-side property.
  • How would a host page listen for an Angular Elements output from a container element instead of the widget itself?
    Not directly: the element dispatches the `CustomEvent` without `bubbles`, so it never reaches ancestors. Either attach the listener to the element, or have the component dispatch its own bubbling event from its host through `ElementRef` for that case, accepting that it is now outside the output mapping.
  • What changed in Angular v21 about reading a signal input from the element?
    The generated getter now unwraps signal inputs, so `el.maxStars` returns the current value exactly as it does for a decorator input. Before v21 it returned the `InputSignal`, and host code had to call `el.maxStars()`; that call now fails because the value is not a function.

saying these in an interview costs you the question

  • Attributes keep camelCase, so markup writes maxStars="10"
  • Output events are renamed to dash-case, like attributes
  • Outputs bubble, so one document listener catches every widget
  • Attribute values are parsed as JSON into objects automatically
  • The page subscribes to outputs as Observables on the element
  • Reading a signal input property in v22 returns the InputSignal