ng-elementum
v3.3.2
Published
<img src="images/logo.svg" width="40%" alt="logo">
Readme
ng-elementum
ng-elementum is a modern fork of @angular/elements that enhances the integration of Angular components with the Web
Components standard. It preserves the simplicity of Angular Elements while adding new powerful features for exposing
component APIs, improving flexibility, and ensuring better developer experience.
Important:
ng-elementumworks only in zoneless mode (nozone.js). See the Zoneless requirement section below.
Overview
ng-elementum packages Angular components
as Custom Elements, also known as Web
Components. These are framework-agnostic HTML elements defined by JavaScript classes and registered with the browser's
CustomElementRegistry.
By transforming Angular components into custom elements, you can:
- Use Angular components outside of Angular applications
- Distribute reusable UI components without requiring Angular knowledge
- Leverage native browser APIs for interoperability
Installation
npm install ng-elementum --saveCompatibility
| ng-elementum | Angular | | ------------ | ------- | | ^0 || ^1 | 20 | | ^2 | 21 | | ^3 | 22 |
Zoneless requirement
ng-elementum relies on Angular's zoneless change detection and must run without zone.js.
createCustomElement()automatically providesprovideZonelessChangeDetection()for the element's internal app config.- Do not load
zone.json the page where your element runs. - If you are composing multiple Angular apps on the same page, ensure they are all zoneless to avoid mixed modes.
How it Works
The createCustomElement() function converts an Angular component into a class that can be registered as a custom
element.
import { createCustomElement, platformElementum } from 'ng-elementum';
import { MyComponent } from './my.component';
const platform = platformElementum([
// platform-level providers
]);
const MyElement = createCustomElement(MyComponent, {
applicationConfig: { providers: [] },
});
customElements.define('my-element', MyElement);Once registered, the element can be used like any other HTML tag:
<my-element message="Hello from ng-elementum!"></my-element>platformElementum
platformElementum() is a dedicated helper for creating an Angular platform that is optimized for
Web Components and embeddable widgets built with ng-elementum.
It creates (or reuses) a single Angular platform instance per page and preconfigures it with everything required for a safe and predictable custom element lifecycle.
In most cases, platformElementum() should be used instead of platformBrowser()
when working with ng-elementum.
Basic usage
import { platformElementum } from 'ng-elementum';
const platform = platformElementum([
// platform-level providers
]);Why a dedicated platform helper?
Angular platforms were originally designed for full-page applications. In the context of Web Components this leads to several issues:
- Multiple widgets must share global services
- Platform-level effects() not working
- Platform-level resource() not working
- When platform is destroyed, all elements are detached from DOM
- When platform recreates, elements are not reattached to DOM
platformElementum() solves these problems by:
- Enabling platform-level effects/resource interop automatically
- Acting as a stable DI root for all elements
- Providing controlled platform lifecycle handling
Automatic element re-creation on platform restart
One important feature of platformElementum() is that it allows
custom elements created with ng-elementum to be safely recreated
when the Angular platform is destroyed and created again.
When the platform is destroyed and later recreated:
- Existing custom elements do not permanently break
- Custom elements are not detached from DOM
- Internal Angular applications are recreated transparently
- Consumers do not need to re-register custom elements manually
Key Features
Inputs and Outputs
- Component inputs become element dash-cased attributes.
- Component outputs are dispatched as dash-cased CustomEvents. The
event name matches the output name (or alias) and the payload is placed on
event.detail.
import { Component, input, output } from '@angular/core';
@Component({ standalone: true, template: `{{ message() }}` })
export class MyComponent {
message = input<string>('');
closed = output<void>();
}<my-element message="Hello"></my-element>const el = document.querySelector('my-element')!;
el.addEventListener('closed', () => console.log('Element closed'));Automatic exposure of signal inputs
In Angular, @Input() properties (and later input() signals) were proxied onto the custom element instance at runtime, but TypeScript typings did not reflect them.
With ng-elementum, all signal inputs defined with input() are both:
- Automatically proxied as runtime properties on the custom element instance
- Automatically reflected in the generated element type, with the correct TypeScript type
This removes the need for manual duplication between runtime behavior and typings.
- Each signal input maps to a writable property on the element
- The property has the correct TypeScript type inferred from the
input<T>()definition - Assigning to that property updates the underlying signal and triggers change detection
@Component({ standalone: true, template: `{{ count() }}` })
export class CounterComponent {
count = input<number>(0);
}
const CounterElement = createCustomElement(CounterComponent, {
applicationConfig: { providers: [] },
});
customElements.define('counter-element', CounterElement);
// Usage with correct typings
const el = document.querySelector('counter-element');
el.count = 42; // ✅ typed as number
el.count = 'hi'; // ❌ compile errorExposing Component Methods
ng-elementum lets you expose component methods directly on the custom element instance:
const MyElement = createCustomElement(MyComponent, {
applicationConfig: { providers: [] },
exposedMethods: ['open', 'close'],
});
customElements.define('my-element', MyElement);
const el = document.querySelector('my-element');
await el.open(); // Calls MyComponent.open()
await el.close(); // Calls MyComponent.close()✅ Preserves method context ✅ Works across Angular and non-Angular hosts ✅ Allows explicit public API definition
Lifecycle hooks: afterConnected / afterDisconnected
ng-elementum lets you react to the custom element being connected to or disconnected from the DOM from within the
component's constructor (or any injection context). This mirrors the native connectedCallback / disconnectedCallback
of custom elements.
import { Component, input } from '@angular/core';
import { afterConnected, afterDisconnected } from 'ng-elementum';
@Component({ standalone: true, template: `...` })
export class MyComponent {
message = input('');
constructor() {
afterConnected(() => {
// Runs after the element is rendered, so inputs are available.
console.log('connected with message:', this.message());
});
afterDisconnected(() => {
// Runs when the element is removed from the DOM.
console.log('disconnected');
});
}
}Notes:
afterConnected(callback)runs each time the element is connected to the DOM. The callback is executed insideafterNextRender, so the component is fully rendered and its inputs are accessible.afterDisconnected(callback)runs each time the element is disconnected from the DOM.afterNextConnected(callback)/afterNextDisconnected(callback)are one-shot variants: they run only the next time the element is connected/disconnected, and are then discarded.- Both functions must be called within an injection context (e.g. the component constructor); calling them outside throws an error.
- The
afterConnected/afterNextConnectedcallbacks themselves run outside an injection context and therefore have no access to DI (inject()inside them throws). - Registration is scoped to the shadow host element (resolved with
{ host: true }), so it is not possible to register hooks on descendants of the shadow host.
Independent routing inside an element
To keep routing scoped to the element and avoid interfering with the host page or other Angular apps, you can provide the router for the element using provideWebComponentRouter, that use an in-memory location implementation so it does not bind to window.location.
import { createCustomElement } from 'ng-elementum';
import { provideWebComponentRouter } from 'ng-elementum/router';
import { Routes, RouterLink } from '@angular/router';
import { Component } from '@angular/core';
@Component({
template: `
<nav>
<a routerLink="/home">Home</a>
<a routerLink="/about">About</a>
</nav>
<router-outlet></router-outlet>
`,
imports: [RouterLink],
})
export class ShellComponent {}
@Component({ template: `Home works!` })
export class HomeCmp {}
@Component({ template: `About works!` })
export class AboutCmp {}
const routes: Routes = [
{ path: 'home', component: HomeCmp },
{ path: 'about', component: AboutCmp },
{ path: '', pathMatch: 'full', redirectTo: 'home' },
];
const RouterElement = createCustomElement(ShellComponent, {
applicationConfig: {
providers: [provideWebComponentRouter(routes)],
},
});
customElements.define('router-element', RouterElement);This element owns its router and URL state, does not conflict with any other Angular app on the page, and supports programmatic navigation through its internal Router.
Router outlet lifecycle hooks: beforeDetach / afterAttach / afterDetach
ng-elementum lets you react to a routed component being attached to or detached from an ancestor RouterOutlet. This is useful for components kept alive via a route reuse strategy, e.g. to refresh data whenever a cached page is shown again, or to preserve scroll position across detach/attach cycles.
import { Component } from '@angular/core';
import { beforeDetach, afterAttach, afterDetach } from 'ng-elementum/router';
@Component({ standalone: true, template: `...` })
export class ProfilePage {
constructor() {
afterAttach(() => {
// Runs each time the page is (re)attached to a router outlet,
// including its initial activation.
this.restoreScrollPosition();
});
beforeDetach(() => {
// Runs just before the view is removed from the DOM, while it is still
// laid out — so layout-dependent state like scrollTop is still readable.
this.saveScrollPosition();
});
afterDetach(() => {
// Runs when the page is detached from a router outlet.
});
}
}Notes:
afterAttach(callback)runs each time the component is attached to an ancestorRouterOutlet, including its initial activation.beforeDetach(callback)runs just before the component is detached, while its view is still in the DOM. It is implemented by patchingRouterOutlet.detach, because the outlet removes the view from layout before emittingdetachEvents. Use it to read layout-dependent state (e.g.scrollTop) before it becomes unavailable.afterDetach(callback)runs when the component is detached from an ancestorRouterOutlet— but note that by this point the view has already been removed from the DOM.afterNextAttach(callback)/afterNextDetach(callback)/beforeNextDetach(callback)are one-shot variants: they run only the next time the matching event occurs, and are then discarded.- The hooks observe every
RouterOutletup the injector hierarchy, so they also react when a parent route (and thus the whole subtree) is re-attached or removed. - If several ancestor outlets detach during a single navigation, the detach hooks fire only once, and they do not fire again while the component is still detached.
- All five functions must be called within an injection context (e.g. the component constructor); calling them outside throws an error.
Using HttpClient in platform level
By default, Angular does not allow HttpClient to be provided at the platform level.
The provideHttpClient() API can only be used in the application root (via bootstrapApplication), not in platformElementum StaticProviders.
To work around this limitation, ng-elementum/http exposes a helper function: createHttpClient().
It allows you to instantiate a standalone HttpClient and register it at the platform level:
import { platformElementum } from 'ng-elementum';
import { createHttpClient } from 'ng-elementum/http';
import { HttpClient } from '@angular/common/http';
const platform = platformElementum([
{
provide: HttpClient,
useFactory: () => createHttpClient(),
},
]);Dynamic ApplicationConfig
ng-elementum supports passing not only a static ApplicationConfig, but also a factory function that returns ApplicationConfig.
The factory is executed inside the platform injection context, so you can use Angular’s inject() API to access platform-level providers while computing the configuration.
Why this is useful
This is especially important for embeddable widgets where:
- Config depends on runtime data (host page, URL, locale, feature flags)
- Platform-level services (bridges, adapters, environment providers) must participate in config creation
Example
import { createCustomElement } from 'ng-elementum';
import { inject } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { MyComponent } from './my.component';
import { HOST_BRIDGE } from './host-bridge.token';
import { API_BASE_URL } from './api.token';
import { authInterceptor } from './auth.interceptor';
const MyElement = createCustomElement(MyComponent, {
applicationConfig: () => {
const hostBridge = inject(HOST_BRIDGE);
const providers = [
// Always present:
{ provide: API_BASE_URL, useValue: hostBridge.getApiBaseUrl() },
// Conditionally present (added only when enabled):
...(hostBridge.isAuthEnabled() ? [provideHttpClient(withInterceptors([authInterceptor]))] : []),
];
return { providers };
},
});
customElements.define('my-element', MyElement);TypeScript Support
Declare typings to unlock IntelliSense and type safety:
import { createCustomElement } from 'ng-elementum';
const MyElement = createCustomElement(/*...*/);
declare global {
interface HTMLElementTagNameMap {
'my-element': InstanceType<typeof MyElement>;
}
}Now TypeScript can infer correct types:
const el = document.createElement('my-element');
await el.open(); // ✅ Type-safeDependency Injection Architecture
ng-elementum introduces a clear two-level DI architecture for custom elements:
Platform scope (providedIn: platform)
- The platform injector acts as the global scope.
- Dependencies registered here are shared across all web elements created on the same page.
- Typical use cases: core Angular providers, common services (e.g. global configuration, theming, analytics).
- There is only one platform per browser page context.
Root scope (providedIn: root)
- Each element instance has its own root injector.
- Providers registered in
applicationConfigwhen callingcreateCustomElement()live in this root scope. - Dependencies in this scope are isolated to the element instance.
- Typical use cases: services that should not leak across elements, component-local state, per-element routing.
Resolution order
- Injector first looks in the element’s root scope.
- If not found, it falls back to the platform scope.
- If still not found, Angular throws an error.
Example
const platform = platformBrowser([
{
provide: AuthService,
},
]);
const MyElement = createCustomElement(MyComponent, {
applicationConfig: {
providers: [UserService],
},
});
customElements.define('my-element', MyElement);Limitations
- Destroying and re-attaching custom elements may cause issues with lifecycle callbacks (see issue).
- Exposed methods must exist on the component class.
- Elements must be attached to the DOM before calling methods.
- Requires zoneless mode (no
zone.js).
Authors
| | | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | | Svyatoslav Zaytsev | | 💻 [email protected] |
License
MIT © 2025
