Exposing a Standalone Component

For this walkthrough, I'll use a straightforward toggle button implemented as a Standalone Component called ToggleComponent:

import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-toggle',
  standalone: true,
  imports: [],
  template: `
    <div class="toggle" [class.active]="active" (click)="toggle()">
      <slot>Toggle!</slot>
    </div>
  `,
  styles: [`
    .toggle {
      padding:10px;
      border: solid black 1px;
      cursor: pointer;
      display: inline
    }

    .active {
      background-color: lightsteelblue;
    }
  `],
  encapsulation: ViewEncapsulation.ShadowDom
})
export class ToggleComponent {

  @Input() active = false;
  @Output() change = new EventEmitter<boolean>();

  toggle(): void {
    this.active = !this.active;
    this.change.emit(this.active);
  }

}

When we set the encapsulation mode to ViewEncapsulation.ShadowDom, the browser takes over and applies native Shadow DOM rather than Angular's emulated variant. A consequence of this choice is that content projection must rely on the standard slot API instead of Angular's ng-content directive.

Setting Up Angular Elements

Although the Angular team maintains @angular/elements, the CLI does not include it by default. As a result, manual installation is necessary:

npm i @angular/elements

Earlier versions of @angular/elements offered ng add support, which included a schematic to install a polyfill. However, since every browser currently supported by Angular handles Web Components natively, that polyfill has become unnecessary. Consequently, the ng add support was dropped several releases ago.

Starting the Application with Angular Elements

Let's now initialize our application and register the ToggleComponent as a Web Component (Custom Element) using Angular Elements. The createApplication function, introduced in Angular 14.2, serves this purpose:

// main.ts

import { createCustomElement } from '@angular/elements';
import { createApplication } from '@angular/platform-browser';
import { ToggleComponent } from './app/toggle/toggle.component';

(async () => {

  const app = await createApplication({
    providers: [
      /* your global providers here */
    ],
  });

  const toogleElement = createCustomElement(ToggleComponent, {
    injector: app.injector,
  });

  customElements.define('my-toggle', toogleElement);

})();

An array of providers can be passed into createApplication. This is how services such as the HttpClient can be made available at the application's root level. In general, this becomes necessary when providers need configuration, for instance through a forRoot method or a provideXYZ function. In all other scenarios, tree-shakable providers (declared with providedIn: 'root') are the recommended approach.

The call to createApplication returns a fresh ApplicationRef. By handing its Injector along with the ToggleComponent to createCustomElement, we obtain a custom element. This element can then be registered with the browser through customElements.define.

One limitation of the current API is that it does not permit injecting a custom zone instance, such as a noop zone. Instead, the Angular team is directing its efforts toward future zone-less change detection capabilities.

Brief Aside: Initializing Multiple Components

The presented API also accommodates the creation of several custom elements in one go:

const element1 = createCustomElement(ThisComponent, {
    injector: app.injector,
});

const element2 = createCustomElement(ThatComponent, {
    injector: app.injector,
});

Beyond custom elements, the same ApplicationRef can bootstrap multiple components as Angular applications:

app.injector.get(NgZone).run(() => {
    app.bootstrap(ToggleComponent, 'my-a');
    app.bootstrap(ToggleComponent, 'my-b');
});

When bootstrapping a component in this manner, you have the option to override the selector that gets used. It's important to remember that bootstrap must be called within a zone to trigger change detection.

In the past, bootstrapping multiple components was achieved by listing them in the bootstrap array of an AppModule. The bootstrapApplication function, which is used for Standalone Components, deliberately avoids this capability to keep the API straightforward for the most frequent scenarios.

Invoking an Angular Element

To utilize our Angular Element, all that's required is placing the appropriate tag inside the index.html:

<h1>Standalone Angular Element Demo</h1>
<my-toggle id="myToggle">Click me!</my-toggle>

Since the browser treats a custom element like any other DOM node, conventional DOM techniques work for attaching event listeners and assigning property values:

<script>
  const myToggle = document.getElementById('myToggle');

  myToggle.addEventListener('change', (event) => {
    console.log('active', event.detail);
  });

  setTimeout(() => {
    myToggle.active = true; 
  }, 3000);
</script>

Using a Web Component within an Angular Component

When a web component is used inside an Angular component, data binding works as expected: square brackets for properties and parentheses for events. This behavior applies whether the web component was built with Angular or another framework.

To illustrate, consider the following AppComponent:

import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@Component({
  selector: 'app-root',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <h2>Root Component</h2>
    <my-toggle 
        [active]="active" 
        (change)="change($event)">
        Hello!
    </my-toggle>
  `,
})
export class AppComponent {
    active = false;
    change(event: Event) {
        const customEvent = event as CustomEvent<boolean>;
        console.log('active', customEvent.detail);
    }
}

This Standalone Component references our my-toggle custom element. While the Angular compiler recognizes all possible Angular components, it has no knowledge of arbitrary web components. This would normally result in an error when it encounters the my-toggle tag. To prevent this, the CUSTOM_ELEMENTS_SCHEMA schema must be registered.

Previously, this schema was added to each NgModule that worked with Web Components. Now, it can be registered directly on a Standalone Component. Functionally, this simply disables the compiler's validation of tag names. This is an all-or-nothing switch — there is no mechanism to inform the compiler about specific web components.

To render this component on the page, we bootstrap it:

// main.ts

[...]
// Register web components ...
[...]

app.injector.get(NgZone).run(() => {
  app.bootstrap(AppComponent);
});

Additionally, an element for AppComponent needs to be added to the index.html:

<app-root></app-root>

Extra: Creating a Self-Contained Bundle

Suppose we only expose a custom element and skip bootstrapping the AppComponent. To make this custom element available to other applications, it must be compiled into a standalone bundle. The default webpack-based builder produces multiple output files, such as a main bundle and a runtime bundle. In contrast, the newer (and still experimental) esbuild-based builder generates just one bundle for the application code and another for polyfills.

To enable it, modify your project configuration in angular.json like this:

"build": {
    "builder": "@angular-devkit/build-angular:browser-esbuild",
    [...]
}

Typically, appending -esbuild to the default builder name is sufficient.

The resulting bundles follow this structure:

favicon.ico (948 bytes)
index.html (703 bytes)
main.43BPAPVS.js (100 177 bytes)
polyfills.M7XCYQVG.js (33 916 bytes)
styles.VFXLKGBH.css (0 bytes)

When embedding the web component into an external site, such as one driven by a CMS, simply include the main bundle and insert the corresponding tag. The polyfills should also be referenced. Just be cautious when using multiple bundles: ensure the polyfills are loaded only once.

The final listing also highlights a tradeoff inherent to Angular Elements: portions of Angular are bundled into the output. This results in an overhead of several kilobytes per bundle.

Looking Ahead: Architecture Considerations

Standalone Components open up interesting architectural possibilities. Still, there are other important questions to address:

  • What criteria should guide the division of a large application into sub-domains?
  • How can we guarantee long-term maintainability over years or even decades?
  • What Micro Frontend options does Module Federation offer?

Our free eBook (roughly 120 pages) explores these topics in depth:

free ebook

You can download it here whenever you're ready!