Angular Elements: Shipping Components as Web Components [2026]
Sooner or later someone asks for "just the product configurator" inside a WordPress page, a React dashboard or a server-rendered checkout that isn't Angular. Rewriting the component isn't attractive, and embedding a whole Angular app in an iframe brings its own problems with sizing, styling and communication. Angular Elements solves this by packaging an Angular component as a standard custom element: a tag like <product-configurator> that any page can use with plain HTML, properties and DOM events.
This is lesson 14.7 of the Angular Tutorial. It follows lesson 14.6 on i18n and relies on signal inputs, outputs and view encapsulation from Module 2. You'll learn how createCustomElement maps a component onto the custom elements API, how to bootstrap it without an app shell, how to ship it as a script, and when web components are the wrong tool.
How an Angular component becomes a custom element #
The browser's custom elements API lets you register a class for a tag name. createCustomElement() from @angular/elements generates that class for an Angular component, and translates between the two worlds:
| Angular component | Custom element |
|---|---|
input() named productId |
Property el.productId and attribute product-id |
output() named addToCart |
CustomEvent named addToCart, payload in event.detail |
| Component created by Angular | Created when the tag is connected to the DOM |
| Component destroyed by Angular | Destroyed after the element is removed from the DOM |
| Root injector | The injector you pass to createCustomElement |
The host page never sees Angular APIs. It sets attributes or properties, listens for events, and inserts or removes the tag like any other element.
Setup #
npm install @angular/elements
Write the component as usual. Signal inputs and output() work as you'd expect:
import { Component, input, output, computed, numberAttribute, booleanAttribute } from '@angular/core';
@Component({
selector: 'app-rating',
template: `
@for (star of stars(); track star) {
<button type="button" [attr.aria-pressed]="star <= value()"
[disabled]="readonly()" (click)="rate(star)">
{{ star <= value() ? '★' : '☆' }}
</button>
}
`,
})
export class Rating {
value = input(0, { transform: numberAttribute });
max = input(5, { transform: numberAttribute });
readonly = input(false, { transform: booleanAttribute });
rated = output<number>();
stars = computed(() => Array.from({ length: this.max() }, (_, i) => i + 1));
rate(star: number) {
this.rated.emit(star);
}
}
Attributes always arrive as strings. The numberAttribute and booleanAttribute transforms convert value="4" and a bare readonly attribute into the types the component expects, so the same component works both inside Angular templates and as a custom element.
Bootstrapping without an app shell #
A standalone app normally starts with bootstrapApplication and a root component in index.html. An element bundle has no root component, so use createApplication to get an ApplicationRef and its injector, then register the elements:
// src/main.ts
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { provideHttpClient } from '@angular/common/http';
import { Rating } from './app/rating';
createApplication({
providers: [provideHttpClient()],
})
.then((appRef) => {
const RatingElement = createCustomElement(Rating, { injector: appRef.injector });
customElements.define('acme-rating', RatingElement);
})
.catch((err) => console.error(err));
Every <acme-rating> on the page now becomes an instance of Rating, and all instances share the providers from createApplication, so a service injected in the component is a single shared instance across elements.
Two rules for the tag name: it must contain a hyphen (a browser requirement), and it should not match the component's selector. If they match, Angular can create two component instances for the same DOM element. A product prefix such as acme- also avoids collisions with other widgets on the page.
Using the element from the host page #
<acme-rating value="3" max="5"></acme-rating>
<script type="module">
const rating = document.querySelector('acme-rating');
rating.addEventListener('rated', (event) => {
console.log('User picked', event.detail);
});
// Properties take real values, not strings
rating.value = 4;
</script>
Attribute names are the input names in dash-case: an input called productId becomes product-id. Properties keep the camelCase name. For objects and arrays, set the property rather than serializing JSON into an attribute. Event names match the output name exactly (or its alias), so a host framework listens for rated, not (rated) or on-rated.
In a TypeScript host, give document.createElement and querySelector the right type once:
import { NgElement, WithProperties } from '@angular/elements';
declare global {
interface HTMLElementTagNameMap {
'acme-rating': NgElement & WithProperties<{ value: number; max: number; readonly: boolean }>;
}
}
Lifecycle and cleanup #
The element's lifecycle follows the DOM, not a router. Angular creates the component when the element is connected to the document and destroys it after the element is removed, so the usual tools apply: inject DestroyRef (or use takeUntilDestroyed) to release timers, subscriptions and listeners you add to window or document. A widget that forgets this leaks on every page that inserts and removes it, which single-page hosts such as React or Vue apps do constantly.
export class LiveStockBadge {
sku = input.required<string>();
stock = signal<number | null>(null);
constructor() {
const api = inject(StockApi);
const timer = setInterval(async () => this.stock.set(await api.level(this.sku())), 30_000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
}
Avoid detaching and re-attaching the same element to move it around the page. Removal schedules destruction, so a moved element can come back as a fresh instance with its internal state reset. If the host needs to move a widget, keep the important state in inputs the host can set again.
Test the component as a normal Angular component with TestBed (lesson 11.3). Add one smoke test that loads the built script in a plain HTML page, sets an attribute, and asserts on the dispatched event, so the custom element wiring itself is covered too.
Style isolation with Shadow DOM #
The default Emulated encapsulation scopes your component's styles, but it doesn't stop the host page's CSS from leaking in. A CMS theme with button { all: unset } will happily restyle your widget. ViewEncapsulation.ShadowDom puts the component's view in a shadow root:
@Component({
selector: 'app-rating',
encapsulation: ViewEncapsulation.ShadowDom,
styles: `
:host { display: inline-flex; gap: 2px; }
button { font-size: var(--acme-rating-size, 1.5rem); color: var(--acme-rating-color, #f5a623); }
`,
template: `...`,
})
export class Rating { /* ... */ }
Emulated (default) |
ShadowDom |
|
|---|---|---|
| Your styles leak out | No | No |
| Page styles leak in | Yes | No (inherited properties like font and color still flow in) |
| Theming hooks | Global classes | CSS custom properties, ::part() |
Global styles (styles.css) apply inside |
Yes | No |
With Shadow DOM, expose deliberate theming hooks such as the CSS custom properties above. Global stylesheets, including a component library theme loaded in styles.css, don't reach inside a shadow root, so component-level styles must be self-contained.
Building and shipping the script #
ng build produces ES module output: a main-[hash].js, any lazy chunks, and a stylesheet. For embedding, a few adjustments help:
"configurations": {
"production": {
"outputHashing": "none",
"budgets": [{ "type": "initial", "maximumWarning": "150kB", "maximumError": "250kB" }]
}
}
Without hashes the file names are stable, so the host page can reference them directly:
<script type="module" src="https://cdn.example.com/widgets/main.js"></script>
Keep the element free of @defer and lazy routes if you want a single file; otherwise deploy the chunks next to main.js, since they are resolved relative to it. Track the initial size with the budget, and use the bundle analysis techniques from lesson 8.5: the host page pays for everything you include.
Zoneless and change detection #
New apps are zoneless by default, and that suits elements well. Without zone.js there is no global patching of setTimeout, promises and event listeners, so your widget doesn't interfere with the host page or with another Angular app that loads its own copy of zone.js, and the bundle is smaller. The cost is the usual zoneless contract: the view refreshes on signal changes, input changes, template events and markForCheck(). Setting a property from the host updates the input, so keep the element's state in signals and derive everything else with computed(), as in Rating above. If a third-party callback mutates a plain field, the view won't update.
When web components are the right choice #
| Good fit | Poor fit |
|---|---|
| Embedding widgets in a CMS, static site or e-commerce platform | Splitting one Angular app into pieces that all run on the same page |
| A micro-frontend that teams on other frameworks consume | Large features with their own routing and deep linking |
| One design-system component that must work in several frameworks | Components that need the host's DI, router or forms integration |
| Gradual migration, replacing parts of a legacy page | SEO-critical content that must be server rendered |
Every independently built element bundle includes its own copy of the Angular runtime it uses, so ten separately built widgets cost more than one bundle that registers ten elements. Group related elements into one createApplication call where you can.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Component renders twice | Tag name equals the component selector |
Use a different, prefixed tag name |
value="4" arrives as the string "4" |
Attributes are always strings | Use numberAttribute / booleanAttribute transforms |
| Host listener never fires | Wrong event name (on-rated, ratedChange) |
Listen for the output's exact name or alias |
| Page CSS breaks the widget | Emulated encapsulation doesn't block inbound styles | ViewEncapsulation.ShadowDom plus CSS custom properties |
| Element loses state when moved in the DOM | Removal triggers destroy; re-insertion creates a new instance | Avoid detaching and re-attaching; keep state in the host or a service |
customElements.define throws "already defined" |
Script loaded twice | Guard with customElements.get('acme-rating') before defining |
What's next #
Lesson 14.8 scales up the other way: multi-project workspaces, building and publishing Angular libraries, and when Nx's project graph, affected commands and caching are worth adopting.
Try it yourself #
Create a new project, build a small standalone component with one input and one output, register it with createApplication and createCustomElement, and load the production build from a plain HTML file that sets a property and logs the event.
get_best_practicesPackage NewsletterSignup as a custom element in its own small project. (1) In main.ts, call createApplication({ providers: [provideHttpClient()] }), then customElements.define('acme-newsletter', createCustomElement(NewsletterSignup, { injector: appRef.injector })); keep the tag different from the component selector. (2) Set encapsulation: ViewEncapsulation.ShadowDom so the theme’s input styles can’t reach inside, and expose --acme-accent and --acme-font custom properties for the colours they may change. (3) Turn the success callback into an output() named subscribed so their analytics can listen for the subscribed event. (4) Build with outputHashing: none, host main.js on your CDN, and give them one line: <script type="module" src="..."></script> plus <acme-newsletter list-id="weekly"></acme-newsletter>.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


