A custom element (Web Component) is an HTML tag you define yourself — <atlas-rating> — that the browser recognises anywhere: in a static page, in a CMS, in a React application or in another Angular application. @angular/elements is the bridge: createCustomElement() turns an Angular component into a class that extends HTMLElement. Inputs become attributes and properties, outputs become CustomEvents, and the component creates itself when the tag enters the DOM.
The examples on this page are real: we define the elements at runtime with customElements.define, create them with document.createElement in a container that Angular does not manage, and listen to them with addEventListener — exactly what a page that knows nothing about Angular would do. Everything runs in this application, which is zoneless.
createCustomElementturns a component into a Web Component you can use on any page: React, Vue, a CMS or plain HTML.- Inputs become properties and attributes (in kebab-case), and outputs become
CustomEventevents. - With Shadow DOM, the element’s styles and the page’s styles no longer mix.
- Every element brings the Angular runtime. Several elements on the same page should come from the same bundle.
- It pays off for widgets embedded in pages you do not control, not for a regular Angular app.
Angular Elements is the right tool when a component has to live outside an Angular application: widgets for a CMS or for clients' websites, a design system used by teams on different frameworks, micro frontends that cannot share the same Angular version. Inside an ordinary Angular application it is almost always the wrong choice: you lose type checking in the template, the DI hierarchy and dynamic content projection.
What createCustomElement does
createCustomElement(Component, { injector }) displays nothing. It returns a class (NgElementConstructor) that you register with customElements.define(tag, class). The class reads the component's metadata (reflectComponentType) and prepares three things:
observedAttributes— the names of the inputs in kebab-case (maxStars→max-stars), so the browser notifies it of every attribute change;- a getter and a setter on the prototype for each input, so
el.maxStars = 7works like an ordinary DOM property; - a strategy (
NgElementStrategy) that, atconnectedCallback, callscreateComponentwith the element ashostElement, attaches the view toApplicationRefand runs a firstdetectChanges()— synchronously, beforeappend()returns.
The injector you pass is where the component gets its services (HttpClient, stores, configuration tokens). In a bundle dedicated to widgets you get it from createApplication(), an application without a root component; in an existing application, from ApplicationRef.injector.
import {
Component,
ViewEncapsulation,
booleanAttribute,
computed,
input,
linkedSignal,
numberAttribute,
output,
} from '@angular/core';
export interface RatingChange {
value: number;
previous: number;
}
@Component({
selector: 'atlas-rating-cmp', // ≠ the tag name (see below)
encapsulation: ViewEncapsulation.ShadowDom, // the host page's CSS does not get in
template: `
@for (s of stars(); track s) {
<button type="button" [class.on]="s <= current()" [disabled]="readonly()"
(click)="pick(s)">★</button>
}
`,
})
export class AtlasRating {
// attributes: value, max-stars, readonly
readonly value = input(0, { transform: numberAttribute });
readonly maxStars = input(5, { transform: numberAttribute });
readonly readonly = input(false, { transform: booleanAttribute });
// becomes a CustomEvent 'ratingChange', the value in event.detail
readonly ratingChange = output<RatingChange>();
// local state, reset when the host sends another value
protected readonly current = linkedSignal(() => this.value());
protected readonly stars = computed(() =>
Array.from({ length: this.maxStars() }, (_, i) => i + 1),
);
protected pick(value: number): void {
const previous = this.current();
this.current.set(value);
this.ratingChange.emit({ value, previous });
}
}The official guide explicitly asks you not to use the component's selector as the tag name. If atlas-rating is both the selector and the custom element, and the component is also imported into a template, Angular creates two instances on the same element. Hence the convention atlas-rating-cmp (selector) vs. atlas-rating (tag). The tag must contain a hyphen, and define() on an already registered name throws NotSupportedError — that is why you check first with customElements.get().
Zone.js is not needed. From v19 the elements use the ordinary change detection scheduler (changelog 19.0.0: “switch to ComponentRef.setInput & remove custom scheduler”), and from v21 applications are zoneless by default. A setAttribute reaches componentRef.setInput(), which marks the view and schedules a render; a click in the element's template changes a signal — exactly as in an ordinary OnPush component.
Defining in an existing application
The typical case: the application receives HTML from a CMS and inserts it with innerHTML. The Angular components in that HTML would never start, but custom elements start on their own, because the browser recognises their tag.
// In an existing application (e.g. HTML from a CMS, inserted with innerHTML)
import { ApplicationRef, inject, provideAppInitializer } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { AtlasRating } from './rating';
function defineAtlasElements(): void {
const injector = inject(ApplicationRef).injector; // root: lives as long as the application
// define() on a name that is already registered throws NotSupportedError
if (!customElements.get('atlas-rating')) {
customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
}
}
export const appConfig = {
providers: [provideAppInitializer(defineAtlasElements)],
};Attributes, properties, events
An element has two “inputs” for the same input. The attribute (max-stars="7") is always a string — that is why numeric or boolean inputs need transform: numberAttribute / booleanAttribute; without it, maxStars() + 1 becomes '71'. The property (el.maxStars = 7) accepts any type, so objects and arrays go through here only. When read, el.maxStars returns the input's current value; from v21 this also holds for input() on signals (previously you had to call el.maxStars()).
| Input / output | HTML attribute | JS property | Worth remembering |
|---|---|---|---|
maxStarstransform: numberAttribute | max-stars="7" | el.maxStars = 7 | the attribute arrives as '7', the transform makes it a number |
readonlytransform: booleanAttribute | readonly | el.readonly = true | removeAttribute sends null → false |
userIdalias: 'user' | user="42" | el.userId | the attribute uses the alias, the property the name in the class |
itemsinput<Item[]> | — | el.items = [...] | through an attribute it would arrive as "[object Object]" |
ratingChangeoutput<T>() | — | addEventListener('ratingChange', …) | CustomEvent, the value in event.detail |
export class Widget {
// the attribute = the input's name (or its alias) in kebab-case
readonly maxStars = input(5, { transform: numberAttribute }); // max-stars="7"
readonly compact = input(false, { transform: booleanAttribute }); // compact
readonly userId = input('', { alias: 'user' }); // user="42" (!)
readonly items = input<Item[]>([]); // property only
// an output becomes a CustomEvent with the output's name (or the alias)
readonly ratingChange = output<RatingChange>(); // 'ratingChange'
readonly closed = output<void>({ alias: 'atlasClosed' }); // 'atlasClosed'
}
// What createCustomElement generates for the class above:
// static observedAttributes = ['max-stars', 'compact', 'user', 'items']
// getter/setter on the prototype: maxStars, compact, userId, items
// (the property uses the class name, the attribute uses the alias)The event is created with new CustomEvent(name, { detail }) — without bubbles and without composed. A listener on document or on the parent container receives nothing; you listen directly on the element. In the example below the container also has a listener for ratingChange: it will never appear in the log.
// press a button or change a fieldel.value: —- 09:11:35 customElements.define('atlas-rating', …) — defined now
Notice one detail: after you click a star, el.value stays the value received from the host. The input is a one-way channel; the component keeps the choice in a linkedSignal and announces it through an event. If the host wants to be the source of truth, it listens to the event and writes el.value back — the “controlled component” pattern.
// The event is created as new CustomEvent(name, { detail })
// → bubbles: false, composed: false.
el.addEventListener('ratingChange', (e: Event) => {
const { value } = (e as CustomEvent<RatingChange>).detail;
});
document.addEventListener('ratingChange', handler); // ✗ receives nothing
// If the host really needs delegation (one listener for 50 widgets),
// emit explicitly, from the component, an event that bubbles and crosses the shadow root:
export class AtlasRating {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private announce(detail: RatingChange): void {
this.host.nativeElement.dispatchEvent(
new CustomEvent('atlas:rating-change', { detail, bubbles: true, composed: true }),
);
}
}Lifecycle
The component is born at connectedCallback and dies at disconnectedCallback, but not immediately: the strategy schedules destroy() after 10 ms (DESTROY_DELAY = 10 in the package source). If the element returns to the DOM before that — a remove() + append() move in the same task, as when sorting a list — the destruction is cancelled and the internal state remains. Inputs set before connection are remembered and applied at creation. Moreover, properties set on an element not yet defined (the widget's script has not loaded) are picked up at upgrade, so the host can set el.items at any time.
document.createElement('atlas-rating')
constructor; no component yet (the inputs set now are remembered)
container.append(el)
connectedCallback → createComponent(...) SYNCHRONOUSLY, attachView(appRef), detectChanges()
el.setAttribute('value', '4')
attributeChangedCallback → componentRef.setInput('value', '4') → transform → CD
el.value = 4
setter on the prototype → the same path, without conversion to a string
el.remove()
disconnectedCallback → destroy SCHEDULED in 10 ms
↳ re-added in < 10 ms (a move) destroy cancelled: same instance, same state
↳ after 10 ms componentRef.destroy() → DestroyRef.onDestroy
container.append(el) // same instance, after destroy
⚠︎ the component is NOT recreated: dead HTML + NG0953 in the console
document.createElement('atlas-rating')
✓ new, clean instanceThe official guide warns (Limitations section, issue #38778) that detaching and re-attaching an element created with @angular/elements is problematic. In v22.2.2, the behaviour can be seen in the demo: once the component has been destroyed, a new append of the same instance does not recreate it. The HTML left in the shadow root is dead (clicks do nothing), el.value is undefined, and Angular reports NG0953 (subscribing to a destroyed OutputRef). A second attempt throws NotSupportedError from attachShadow, because an element cannot have two shadow roots.
It happens when someone reuses the DOM node: AngularJS's ng-if, a CMS editor that moves blocks, an element cache in your code. The solution: do not reuse the instance — create another element with document.createElement — or, if you only want to hide it, leave it in the DOM with the hidden attribute.
Shadow DOM and styles
With Emulated (the default), Angular rewrites the component's selectors with unique attributes: your styles do not leak out, but the page's CSS comes in. A widget placed on a foreign site inherits any rule such as .content p or button { text-transform: uppercase !important } — the most common bug in widgets. With ViewEncapsulation.ShadowDom, the template is rendered in a shadow root (mode open), and the page's selectors no longer see inside. Only inherited properties (font, color) and CSS variables pass through — exactly what you need for a controlled theme.
A little-known nuance: in ShadowDom mode, Angular copies into each shadow root the styles of the other components in the same application too. For Emulated components this is dead weight (their selectors do not match), but a component with ViewEncapsulation.None ends up styling the inside of the widget as well. ExperimentalIsolatedShadowDom (experimental since v21) puts only the component's own styles in the shadow root. During SSR, both shadow modes are rendered as Emulated.
| Encapsulation | Does the page's CSS get in? | None styles from the application | CSS variables |
|---|---|---|---|
Emulated (default) | yes | yes | yes |
ShadowDom | no | yes (copied into the shadow root) | yes |
ExperimentalIsolatedShadowDom | no | no | yes |
| Element | el.shadowRoot | <style> in shadow root |
|---|
Shadow DOM changes more than styles: events that leave a shadow root are retargeted (event.target becomes the host), document.querySelector finds nothing from the inside, and e2e tests have to pierce the shadow root. For theming, expose CSS variables and, where more control is needed, part attributes styleable with ::part().
@Component({
selector: 'atlas-card-cmp',
encapsulation: ViewEncapsulation.ShadowDom,
template: `
<h3 part="title">{{ title() }}</h3>
<ng-content />
`,
styles: `
:host { display: block; }
/* CSS variables and inherited properties (font, color) DO enter the shadow root */
h3 { color: var(--atlas-accent, #2b45d4); font: inherit; font-weight: 650; }
`,
})
export class AtlasCard {
readonly title = input('');
}Build, bundle, distribution
To use the elements outside an Angular application, you need a separate build: an application whose main.ts calls createApplication() and customElements.define(), without a root component. The Native Federation schematic even has the --webcomponent option, which generates exactly this bootstrap (see Micro frontends).
The cost is the runtime. A single simple element, built with Angular 22.2.2 in production, zoneless, comes to ≈ 110 kB minified, ≈ 33 kB transferred (the CLI estimate) — practically @angular/core + platform-browser + a bit of RxJS. Three widgets from three different builds on the same page mean three copies of the runtime, three injectors and three applications that share no service.
- Group a team's widgets into a single bundle: one
createApplication(), severaldefine()calls. - Stable names for integration via
<script src>:--output-hashing=noneand versioned folders (/1.4.0/main.js), so you can use a long cache without a hash in the name. - Lazy inside:
@deferandimport()also work inside an element, so the heavy part of the widget can arrive when it is needed.
# a separate project in the workspace, just for widgets
ng generate application atlas-widgets --ssr=false
# main.ts: createApplication + customElements.define (see main.ts above)
# stable file names, so that a CMS can include <script src=".../main.js">
ng build atlas-widgets --output-hashing=none
# → publish into versioned folders: /atlas-widgets/1.4.0/main.js
# (long cache, no hash in the name; the lazy chunks keep their hash)
# measured with Angular 22.2.2, one simple element, zoneless, production build:
# main.js ≈ 110 kB minified, ≈ 33 kB transfer estimated by the CLI
# each bundle built separately brings its own copy of
# @angular/core + @angular/platform-browserUse from other environments
For static HTML a <script type="module"> and the tag are enough. Each of the other environments has a small configuration detail:
- Angular: the host component declares
schemas: [CUSTOM_ELEMENTS_SCHEMA]. The[prop]and(event)bindings work, but without type checking — if you are in the same application, use the component directly. - React 19 has full support for custom elements: props that match a property of the element are set as DOM properties (in earlier versions they arrived as attributes). For custom events, the portable option is a
ref+addEventListener. - Vue 3 must know that the tag is not a Vue component: the compile option
isCustomElement. - CMSs: the editor only inserts HTML, so everything has to be configurable through string attributes. Design the inputs as primitives; for complex configurations, use a JSON attribute with a transform that calls
JSON.parse.
// Using a custom element (yours or another team's) in an Angular component
import { CUSTOM_ELEMENTS_SCHEMA, Component, signal } from '@angular/core';
@Component({
selector: 'app-product',
// without the schema: error "'atlas-rating' is not a known element"
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<atlas-rating
[value]="rating()"
max-stars="5"
(ratingChange)="rating.set($any($event).detail.value)"
></atlas-rating>
`,
})
export class Product {
protected readonly rating = signal(4);
}
// [value] sets the DOM property, (ratingChange) does addEventListener —
// but without any type checking.The content of a custom element appears only after the script has downloaded and run. For text that is essential for SEO or LCP (the page title, the price, the main content), a client-side widget is the wrong choice — see SSR.
Communication between applications
When several independent applications live on the same page (widgets from different teams, micro frontends), they do not share an injector. The platform's mechanisms remain, in the order worth trying:
| Mechanism | Reach | What you can send | Watch out for |
|---|---|---|---|
| Attributes / properties + events on the element | the host ↔ its element | anything (properties), detail (events) | it is the widget's public contract: version it |
CustomEvent on window | the same page | any object (a reference, not a copy) | prefixed names, the “late subscriber”, it is not state |
BroadcastChannel | tabs, windows, iframes, workers — same origin | cloneable values (structured clone) | does not reach the sender; close() at the end |
postMessage | iframe / window, including another origin | cloneable values | an explicit targetOrigin, check event.origin |
A bus on window is the simplest contract between applications on the same page: prefixed event names (atlas:cart:add), types in a small, versioned package, two functions emit/listen. The classic trap is that an event is not state: an application mounted later missed everything that happened before. The solution is a handshake — on start you ask for the state, and its owner replies.
// contracts/atlas-events.ts — a small, versioned package, shared by the teams
export interface AtlasEvents {
'atlas:cart:add': { sku: string; qty: number };
'atlas:cart:changed': { count: number; total: number };
'atlas:cart:state-request': { from: string };
}
export function emit<K extends keyof AtlasEvents>(type: K, detail: AtlasEvents[K]): void {
window.dispatchEvent(new CustomEvent(type, { detail }));
}
export function listen<K extends keyof AtlasEvents>(
type: K,
fn: (detail: AtlasEvents[K]) => void,
): () => void {
const handler = (e: Event) => fn((e as CustomEvent<AtlasEvents[K]>).detail);
window.addEventListener(type, handler);
return () => window.removeEventListener(type, handler);
}window (a third, independent listener)- Add a product from A.
BroadcastChannel links all the contexts of the same origin that open a channel with the same name: tabs, windows, iframes, workers. The message is copied through structured clone (no functions, no class instances with methods) and does not reach the object that sent it. Typical uses: logout in all tabs, synchronising the cart, invalidating a cache. For cross-origin iframes, postMessage remains, with the origin verified at both ends.
- Open a second tab and press +1 there.
// Same origin: other tabs, windows, iframes, workers
@Injectable({ providedIn: 'root' })
export class SessionSync {
private readonly channel = new BroadcastChannel('atlas-session');
private readonly router = inject(Router);
constructor() {
this.channel.onmessage = (e: MessageEvent<{ type: 'logout' }>) => {
if (e.data?.type === 'logout') this.router.navigateByUrl('/login');
};
inject(DestroyRef).onDestroy(() => this.channel.close());
}
logoutEverywhere(): void {
// reaches the other tabs, NOT this object
this.channel.postMessage({ type: 'logout' });
}
}When YES, when NO
- Widgets for pages you do not control: a CMS (WordPress, AEM, Sitecore), clients' websites, a support chat, a product configurator.
- A design system used from several frameworks, when its team writes Angular.
- Interactive islands inside a server-side application (PHP, Java, .NET) that will not be rewritten soon.
- Micro frontends that cannot share the same Angular version: each element brings its runtime, so the versions can differ.
- Dynamic HTML (from a CMS, from markdown) inserted with
innerHTMLinto an Angular application, where ordinary components would not start.
- Inside the same Angular application → ordinary components (or dynamic components with
createComponent/NgComponentOutlet). - Content critical for SEO or LCP → server-side rendering (SSR, SSG), not a client-side widget.
- Several Angular applications on the same version that must share the runtime → Native Federation, not N separate bundles.
- Only to reuse code between the Angular applications of the same team → a library in a monorepo.
- Tiny widgets on weight-sensitive pages (a “share” button) → 33 kB of runtime is not justified; a hand-written web component or Lit is a better fit.
Common pitfalls
- The component's selector used as the tag → two instances on the same element. Use a separate selector (
atlas-rating-cmp). define()called twice (HMR, tests, two bundles defining the same tag) →NotSupportedError. CheckcustomElements.get(); between teams, prefix the tags with the product name.- Expecting events to bubble up → a listener on
documentthat receives nothing. Listen on the element, or re-emit explicitly withbubbles: true, composed: true. - Outputs named
change,input,click→ they mix with the native events of the elements inside that reach the host. Use specific names (ratingChange). - A numeric input without a transform → the attribute arrives as a string and
maxStars() + 1becomes'71'. PutnumberAttribute/booleanAttributeon every input that can be configured from HTML. - Objects through attributes → the component receives
"[object Object]". Send objects and arrays as properties. - Expecting the element to see the parent component's services → it uses the injector given to
createCustomElement, not the place in the DOM where you put it. Services from a component's or a route'sprovidersdo not reach it. - Children added after creation → the nodes for
<ng-content>are collected only once, at connection. Dynamic content is sent through properties. - Reusing a destroyed element → dead HTML,
NG0953, thenNotSupportedError. Create a new element. - Listeners and channels not closed → after each mount, one more
window.addEventListeneror one more openBroadcastChannel. Clean up inDestroyRef.onDestroy. postMessage(data, '*')and no check ofevent.origin→ any page that frames you in an iframe can read or send messages. See Security.
Cheat sheet
// 1. The component: inputs with transform for attributes, outputs with specific names
readonly maxStars = input(5, { transform: numberAttribute }); // <x max-stars="7">
readonly ratingChange = output<RatingChange>(); // CustomEvent, detail
// 2. Definition: once only, tag ≠ selector, tag with a hyphen
createApplication({ providers: [] }).then(({ injector }) => { // separate bundle
// (in an existing application: const injector = inject(ApplicationRef).injector)
if (!customElements.get('atlas-rating')) {
customElements.define('atlas-rating', createCustomElement(AtlasRating, { injector }));
}
});
// 3. Usage
el.setAttribute('max-stars', '7'); // string → transform
el.maxStars = 7; // any type
el.addEventListener('ratingChange', fn); // on the element: bubbles false, composed false
// 4. Lifecycle: append = create (synchronous); remove = destroy after 10 ms;
// do not re-attach a destroyed instance → create another element
// 5. Styles: ViewEncapsulation.ShadowDom + CSS variables + ::part() for theming
// 6. Communication: attributes/properties/events → CustomEvent on window (typed)
// → BroadcastChannel (tabs, same origin) → postMessage + origin (iframes)