With Angular Elements, how do a component's inputs and outputs map to the custom element's attributes, properties and DOM events?
answer
- strings in, objects by property
- alias, then dash-case
- property keeps the class field name
- outputs become CustomEvent, payload in detail
- dispatched on the host, no bubbling
basics
~20 sEach 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 linesimport { 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
Recall the three channels: dash-case attributes and properties for inputs, a CustomEvent with detail for each output.
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.
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.
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