| |

Angular 92 🅰️ Angular and Web Components

⏱️ Estimated read: 13 min | |

Angular and Web Components are two different component models that solve the same problem: packaging reusable UI. Angular components are Angular-specific — they use Angular’s template syntax, dependency injection, and change detection, and they only work inside an Angular application. Web Components are a browser standard — they are native HTML elements that work in any framework or in plain HTML. For most of Angular’s history, these two models were separate. Angular Elements is the bridge that lets you package an Angular component as a Web Component and use it anywhere .

Key point: Angular Elements is a package (@angular/elements) that provides a createCustomElement() function. This function takes an Angular component and returns a class that the browser can register as a custom element. The result is an HTML tag that looks and behaves like any other native element — it bootstraps itself when added to the DOM, destroys itself when removed, and communicates through standard HTML attributes and DOM events .


Why Angular Elements exists

Angular components are not portable. A mat-card or a custom UserProfileComponent only works inside an Angular application. If a React team wants to reuse it, or a legacy jQuery page needs to embed it, the component cannot cross the boundary. This is the fundamental limitation that Angular Elements addresses.

The cross-framework problem. A company with a design system built in Angular wants other teams using React or Vue to reuse the components. Without Angular Elements, the other teams must either rewrite the components in their own framework or embed an entire Angular application inside their page. Angular Elements packages a single component as a standard HTML element that any framework can consume .

The micro-frontend problem. A legacy application is being migrated to Angular incrementally. Instead of rewriting the entire application, individual features can be built as Angular Elements and dropped into the existing page. The legacy application does not need to know about Angular, and Angular does not need to know about the legacy application .

The dynamic content problem. A CMS or a dashboard application needs to render widgets that are defined at runtime. With Angular Elements, the widget is a custom element. Adding it to the DOM bootstraps the Angular component. Removing it destroys the component. No manual lifecycle management is required .

The trade-off. Angular Elements adds bundle size. A single Angular Element includes the Angular runtime, the component, and the bridge code. This is heavier than a native Web Component written without a framework. The trade-off is development speed and consistency: the team writes Angular, and the output works anywhere. For applications where bundle size is critical, a native Web Component or a lighter framework may be a better choice.


a. Creating a Custom Element

The createCustomElement() function is the entry point. It takes two arguments: the Angular component and a configuration object that provides the injector for the component’s dependencies .

import { createCustomElement } from '@angular/elements';
import { createApplication } from '@angular/platform-browser';
import { UserProfileComponent } from './user-profile.component';

const app = await createApplication({
  providers: [],
});

const UserProfileElement = createCustomElement(UserProfileComponent, {
  injector: app.injector,
});

customElements.define('user-profile', UserProfileElement);

The createApplication() function bootstraps a minimal Angular application that provides the injector. The createCustomElement() function wraps the component. The customElements.define() call registers the element with the browser. After registration, the <user-profile> tag is a valid HTML element that renders the Angular component .

A critical rule: do not use the component’s selector as the custom element tag name . If the component selector is app-user-profile, the custom element tag should be user-profile, not app-user-profile. Using the same name causes Angular to create two component instances for a single DOM element — one as a regular Angular component and one as a custom element. This is a known source of confusing bugs.


b. Mapping Inputs and Outputs

The bridge between Angular’s component API and the DOM’s attribute/event model is automatic. Input properties become HTML attributes, and output events become DOM custom events .

Inputs are transformed to dash-case attributes. An @Input('userName') property becomes the user-name attribute. The transformation is necessary because HTML attributes are case-insensitive, and custom elements do not recognize camelCase .

<user-profile user-name="Alice" role="admin"></user-profile>

Outputs are dispatched as custom events. The event name matches the output name. An @Output() valueChanged emits a DOM event named valueChanged. The emitted data is stored on the event’s detail property .

element.addEventListener('valueChanged', (event) => {
  console.log(event.detail); // the emitted value
});

For components that use Angular’s newer signal-based inputs and outputs, the mapping is similar. Signal inputs are read as attributes, and signal outputs emit events. The bridge handles the conversion.


c. Consuming Web Components in Angular

The opposite direction is also common: an Angular application needs to use a Web Component built by another team or provided by a third-party library. Angular does not recognize custom elements by default. The template compiler sees a dash-case tag and reports an error unless the element is declared as a known schema .

The CUSTOM_ELEMENTS_SCHEMA allows an Angular module to contain non-Angular elements with dash-case names . Adding it to the schemas array of a component or module tells Angular to skip validation for those elements.

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

@Component({
  selector: 'app-root',
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<utrecht-heading level="1">Home</utrecht-heading>`,
})
export class AppComponent {}

For a cleaner integration, a wrapper component can be created for each Web Component. The wrapper provides Angular’s type safety, form integration, and property binding. The @utrecht/web-component-library-angular package is an example of this pattern: it provides Angular wrappers for Stencil-based Web Components, avoiding the need for CUSTOM_ELEMENTS_SCHEMA and enabling Angular form bindings .

Change detection and Zone.js. Web Components do not use Zone.js. When a Web Component updates its internal state or fires events, Angular’s change detection does not automatically run. If the Web Component’s output is bound to an Angular template, the binding may not update . The solution is to re-enter the Angular zone when the Web Component dispatches an event that should trigger change detection.

element.addEventListener('valueChanged', (event) => {
  this.ngZone.run(() => {
    this.value.set(event.detail);
  });
});

Running the event handler inside NgZone.run() ensures that Angular’s change detection is scheduled and the template updates .


Complete Example Session

This session builds an Angular component, packages it as a custom element, and consumes it from a plain HTML page.

// ============================================
// PART 1: THE COMPONENT
// ============================================

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

@Component({
  selector: 'app-user-card',
  template: `
    <div class="card">
      <h3>{{ name() }}</h3>
      <p>{{ role() }}</p>
      <button (click)="select.emit(name())">Select</button>
    </div>
  `,
  styles: [`
    .card { border: 1px solid #ccc; padding: 16px; border-radius: 8px; }
    h3 { margin: 0 0 8px; }
    button { margin-top: 8px; }
  `],
})
export class UserCardComponent {
  name = input.required<string>();
  role = input<string>('User');
  select = output<string>();
}

The component uses signal inputs and outputs. The name input is required. The role input has a default. The select output emits the name when the button is clicked.

// ============================================
// PART 2: THE CUSTOM ELEMENT BOOTSTRAP
// ============================================

import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { UserCardComponent } from './user-card.component';

(async () => {
  const app = await createApplication({
    providers: [],
  });

  const UserCardElement = createCustomElement(UserCardComponent, {
    injector: app.injector,
  });

  customElements.define('user-card', UserCardElement);
})();

The createApplication() bootstraps a minimal Angular application. The createCustomElement() wraps the component. The customElements.define() registers the element with the browser.

<!-- ============================================
     PART 3: THE CONSUMING HTML PAGE
     ============================================ -->

<!DOCTYPE html>
<html>
<head>
  <title>User Card Demo</title>
  <script type="module" src="./main.js"></script>
</head>
<body>
  <h1>User Cards</h1>

  <user-card name="Alice Johnson" role="Admin"></user-card>
  <user-card name="Bob Smith" role="Editor"></user-card>

  <script>
    document.querySelector('user-card').addEventListener('select', (event) => {
      console.log('Selected:', event.detail);
    });
  </script>
</body>
</html>

The custom element is used like any other HTML tag. The name and role attributes map to the component’s inputs. The select event fires when the button is clicked, and the event’s detail property contains the emitted value.

// ============================================
// PART 4: THE ANGULAR CONSUMER
// ============================================

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

@Component({
  selector: 'app-root',
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <h1>Angular Consumer</h1>
    <user-card
      [attr.name]="userName"
      [attr.role]="userRole"
      (select)="onSelect($event)"
    ></user-card>
  `,
})
export class AppComponent {
  userName = 'Carol Williams';
  userRole = 'Viewer';

  onSelect(event: Event): void {
    const customEvent = event as CustomEvent<string>;
    console.log('Selected:', customEvent.detail);
  }
}

The CUSTOM_ELEMENTS_SCHEMA allows Angular to recognize the user-card element. The attributes are bound with [attr.name] and [attr.role] because custom element inputs are attributes, not properties. The (select) event binding listens for the custom event, and the event is cast to CustomEvent to access the detail property.

// ============================================
// PART 5: THE ZONE.JS CONSIDERATION
// ============================================

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

@Component({
  selector: 'app-root',
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<user-card (select)="onSelect($event)"></user-card>`,
})
export class AppComponent {
  private ngZone = inject(NgZone);

  onSelect(event: Event): void {
    const customEvent = event as CustomEvent<string>;
    this.ngZone.run(() => {
      // Update Angular state here.
      // Without NgZone.run, the template may not update.
      console.log('Selected:', customEvent.detail);
    });
  }
}

If the custom element is created outside Angular’s zone, its events fire outside the zone. Running the handler inside NgZone.run() ensures that change detection runs and the template updates .


Quick Reference

The createCustomElement API

ParameterTypePurpose
ComponentType<T>The Angular component to wrap
Config{ injector: Injector }Provides dependencies

The Input/Output Mapping

AngularCustom ElementHTML
@Input('userName')user-name attribute<el user-name="...">
@Output() selectselect eventel.addEventListener('select', ...)
event.detailEmitted valueevent.detail

The Angular Consumer Options

OptionPurpose
CUSTOM_ELEMENTS_SCHEMAAllow dash-case elements
[attr.name]Bind to custom element attributes
(eventName)Listen for custom events
NgZone.run()Re-enter Angular zone for change detection

The When to Use

ScenarioUse Angular Elements?
Reuse Angular components in React/Vue✅ Yes
Micro-frontend migration✅ Yes
CMS widgets✅ Yes
Single-framework application❌ No
Bundle-size-critical application❌ No

Best Practices

✅ Do This:

// Use a dash-case tag name different from the selector
customElements.define('user-card', UserCardElement);             // ✅
// Use CUSTOM_ELEMENTS_SCHEMA in the consuming component
schemas: [CUSTOM_ELEMENTS_SCHEMA]                                // ✅
// Cast events to CustomEvent to access detail
const customEvent = event as CustomEvent<string>;                // ✅
// Re-enter the Angular zone for change detection
this.ngZone.run(() => { /* update state */ });                   // ✅

❌ Don’t Do This:

// Don't use the component selector as the custom element tag
customElements.define('app-user-card', UserCardElement);         // ❌ two instances
<!-- Don't bind properties directly on custom elements -->
<user-card [name]="userName"></user-card>                        <!-- ❌ use attr. -->
// Don't expect change detection to run automatically
element.addEventListener('event', (e) => { this.value = e.detail; }); // ❌ outside zone

Common Pitfalls

PitfallWhy It HappensFix
Two component instancesSelector used as tag nameUse a different dash-case tag
Inputs not receivedProperty binding used instead of attributeUse [attr.name]
Template not updatingEvent fired outside Angular zoneUse NgZone.run()
Angular error on custom elementSchema not declaredAdd CUSTOM_ELEMENTS_SCHEMA
Dependencies not injectedNo injector providedPass app.injector to createCustomElement

Real-World Examples

1. Register a Custom Element

customElements.define('my-widget', createCustomElement(MyWidgetComponent, { injector }));

2. Use in Plain HTML

<my-widget message="Hello"></my-widget>

3. Listen for Events

document.querySelector('my-widget').addEventListener('changed', (e) => console.log(e.detail));

4. Angular Consumer

<my-widget [attr.message]="msg" (changed)="onChanged($event)"></my-widget>

5. Schema Declaration

schemas: [CUSTOM_ELEMENTS_SCHEMA]

6. Zone Re-entry

this.ngZone.run(() => this.value.set(e.detail));

7. Wrapper Component

@Component({ selector: 'app-wrapper', template: '<my-widget></my-widget>' })

8. Stencil Wrappers

import { UtrechtWebComponentsModule } from '@utrecht/web-component-library-angular';

9. Dynamic Loading

const element = document.createElement('my-widget');
document.body.appendChild(element);

10. Removal

element.remove(); // destroys the Angular component

Visual

The Angular Elements Bridge

┌──────────────────────────────────────────────┐
│  ANGULAR COMPONENT                           │
│    @Input, @Output, template, DI             │
│       │                                      │
│       ▼                                      │
│  createCustomElement()                       │
│    ├─ Wraps the component                    │
│    ├─ Maps inputs to attributes              │
│    └─ Maps outputs to DOM events             │
│       │                                      │
│       ▼                                      │
│  customElements.define('tag-name', Class)    │
│       │                                      │
│       ▼                                      │
│  NATIVE HTML ELEMENT                         │
│    Works in any framework                    │
│    Bootstraps on DOM insertion               │
│    Destroys on removal                       │
│                                              │
└──────────────────────────────────────────────┘

The Input/Output Mapping

┌──────────────────────────────────────────────┐
│  ANGULAR                                     │
│    @Input('userName') userName: string       │
│    @Output() select = new EventEmitter()     │
│                                              │
│  CUSTOM ELEMENT                              │
│    <user-card user-name="Alice">             │
│    element.addEventListener('select', ...)   │
│                                              │
│  The mapping is automatic.                   │
│  Inputs become dash-case attributes.         │
│  Outputs become DOM custom events.           │
│                                              │
└──────────────────────────────────────────────┘

The Angular Consumer Flow

┌──────────────────────────────────────────────┐
│  ANGULAR TEMPLATE                            │
│    <user-card [attr.name]="userName"         │
│               (select)="onSelect($event)">   │
│                                              │
│  CUSTOM ELEMENTS SCHEMA                      │
│    Allows dash-case elements                 │
│                                              │
│  ZONE.JS                                     │
│    Custom element events fire outside zone   │
│    Use NgZone.run() to re-enter              │
│                                              │
└──────────────────────────────────────────────┘

The Cross-Framework Use Case

┌──────────────────────────────────────────────┐
│  ANGULAR TEAM                                │
│    Builds UserProfileComponent               │
│    Packages as <user-profile>                │
│                                              │
│  REACT TEAM                                  │
│    Imports the JS bundle                     │
│    Uses <user-profile> in JSX                │
│                                              │
│  VUE TEAM                                    │
│    Imports the JS bundle                     │
│    Uses <user-profile> in template           │
│                                              │
│  The component works everywhere.             │
│  No framework-specific wrapper required.     │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Package@angular/elements
FunctioncreateCustomElement()
RegistrationcustomElements.define()
Input mappingDash-case attributes
Output mappingDOM custom events
Event dataevent.detail
Angular consumerCUSTOM_ELEMENTS_SCHEMA
Change detectionNgZone.run()
Tag namingMust differ from selector
Use casesCross-framework, micro-frontends, CMS

Key takeaways:

  • Angular Elements packages an Angular component as a native Web Component. The createCustomElement() function wraps the component, maps its inputs and outputs to DOM attributes and events, and returns a class that the browser can register .
  • Inputs become dash-case attributes. An @Input('userName') is exposed as the user-name attribute. HTML attributes are case-insensitive, so the bridge transforms the names to dash-case lowercase .
  • Outputs become DOM custom events. An @Output() select dispatches a select event. The emitted value is stored on the event’s detail property. The consuming code uses addEventListener or Angular’s event binding to listen .
  • The component selector must not be used as the custom element tag name. Using the same name causes Angular to create two instances of the component for a single DOM element. Use a different dash-case tag name .
  • Angular consumers need CUSTOM_ELEMENTS_SCHEMA. The schema allows dash-case elements in the template. Without it, Angular reports an error for unknown elements .
  • Web Component events fire outside Angular’s zone. If the custom element was initialized outside the zone, its events do not trigger change detection. Use NgZone.run() to re-enter the zone and update Angular state .
  • The trade-off is bundle size. An Angular Element includes the Angular runtime. For applications where bundle size is critical, a native Web Component or a lighter framework may be a better choice.

Remember: Angular Elements is the bridge between Angular’s component model and the browser’s native component model. It lets you package an Angular component as a custom element that works in React, Vue, plain HTML, or any other environment. The inputs become attributes. The outputs become events. The component bootstraps itself when added to the DOM. The bridge is automatic, but the zone boundary is not — custom element events fire outside Angular’s zone, and the consumer must re-enter it for change detection to run.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!