Angular Elements: Web Components with Standalone Components

Since Angular 14.2, it's possible to use Standalone Components as Angular Elements.

  1. Angular’s Future Without NgModules – Part 1: Lightweight Solutions Using Standalone Components
  2. Angular’s Future Without NgModules – Part 2: What Does That Mean for Our Architecture?
  3. 4 Ways to Prepare for Angular’s Upcoming Standalone Components
  4. Routing and Lazy Loading with Angular’s Standalone Components
  5. Angular Elements: Web Components with Standalone Components
  6. The Refurbished HttpClient in Angular 15 – Standalone APIs and Functional Interceptors
  7. Testing Angular Standalone Components
  8. Automatic Migration to Standalone Components in 3 Steps

Since Angular 14.2, it\'s possible to use Standalone Components as Angular Elements. In this article, I\'m going to show you, how this new feature works.

đź“‚ Source Code

Providing a Standalone Component

The Standalone Component I\'m going to use here is a simple Toggle Button 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);
  }

}

By setting encapsulation to ViewEncapsulation.ShadowDom, I\'m making the browser to use "real" Shadow DOM instead of Angular\'s emulated counterpart. However, this also means that we have to use the Browser\'s slot API for content projection instead of Angular\'s ng-content.

Installing Angular Elements

While Angular Elements is directly provided by the Angular team, the CLI doesn\'t install it. Hence, we need to do this by hand:

npm i @angular/elements

In former days, @angular/elements also supported ng add. This support came with a schematic for adding a needed polyfill. However, meanwhile, all browsers supported by Angular can deal with Web Components natively. Hence, there is no need for such a polyfill anymore and so the support for ng add was already removed some versions ago.

Bootstrapping with Angular Elements

Now, let\'s bootstrap our application and expose the ToggleComponent as a Web Component (Custom Element) with Angular Elements. For this, we can use the function createApplication added with Angular 14.2:

// 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);

})();

We could pass an array with providers to createApplication. This allows to provide services like the HttpClient via the application\'s root scope. In general, this option is needed when we want to configure these providers, e. g. with a forRoot method or a provideXYZ function. In all other cases, it\'s preferable to just go with tree-shakable providers (providedIn: 'root').

The result of createApplication is a new ApplicationRef. We can pass it\'s Injector alongside the ToggleComponent to createCustomElement. The result is a custom element that can be registered with the browser using customElements.define.

Please note that the current API does not allow for setting an own zone instance like the noop zone. Instead, the Angular team wants to concentrate on new features for zone-less change detection in the future.

Side Note: Bootstrapping Multiple Components

The API shown also allows to create several custom elements:

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

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

Besides working with custom elements, the ApplicationRef at hand also allows for bootstrapping several components as Angular applications:

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

When bootstrapping a component this way, one can overwrite the selector to use. Please note, that one has to call bootstrap within a zone in order to get change detection.

Bootstrapping several components was originally done by placing several components in your AppModule\'s bootstrap array. The bootstrapApplication function used for bootstrapping Standalone Components does, however, not allow for this as the goal was to provide a simple API for the most common use case.

Calling an Angular Element

To call our Angular Element, we just need to place a respective tag in our index.html:

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

As a custom element is threaded by the browser as a normal DOM node, we can use traditional DOM calls to set up events and to assign values to properties:

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

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

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

Calling a Web Component in an Angular Component

If we call a web component within an Angular component, we can directly data bind to it using brackets for properties and parenthesis for events. This works regardless whether the web component was created with Angular or not.

To demonstrate this, let\'s assume we have 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 calls our my-toggle web component. While the Angular compiler is aware of all possible Angular components, it doesn\'t know about web components. Hence, it would throw an error when seeing the my-toggle tag. To avoid this, we need to register the CUSTOM_ELEMENTS_SCHEMA schema.

Before, we did this with all the NgModules we wanted to use together with Web Components. Now, we can directly register this schema with Standalone Components. Technically, this just disables the compiler checks regarding possible tag names. This is binary - the checks are either on or off -- and there is no way to directly tell the compiler about the available web components.

To make this component appear on our page, we need to bootstrap it:

// main.ts

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

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

Also, we need to add an element for AppComponent to the index.html:

<app-root></app-root>

Bonus: Compiling Self-contained Bundle

Now, let\'s assume, we only provide a custom element and don\'t bootstrap our AppComponent. In order to use this custom element in other applications, we need to compile it into a self contained bundle. While the default webpack-based builder emits several bundles, e. g. a main bundle and a runtime bundle, the new -- still experimental -- esbuild-based one just gives us one bundle for our source code and another one for the polyfills.

To activate it, adjust your project settings in your angular.json as follows:

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

Normally, you just have to add -esbuild at the end of the default builder.

The resulting bundles look like this:

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)

If you use your web component in an other web site, e. g. a CMS-driven one, just reference the main bundle there and add a respective tag. Also, reference the polyfills. However, when using several such bundles, you have to make sure, you only load the polyfills once.

The last listing also shows a tradeoff of Angular Elements: Parts of Angular end up in the bundle. Hence, we have an overhead of some KB per bundle.

What\'s next? More on Architecture!

Standalone Components are quite interesting for our architectures. However, there are also other topics we need to considern:

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

Our free eBook (about 120 pages) covers all these questions and more:

free

Feel free to download it here now!