| |

Angular 116 🅰️ Angular Elements

⏱️ Estimated read: 13 min | |

Angular Elements packages Angular components as custom elements—a browser standard for defining new HTML tags in a framework-agnostic way. The result is a component that any web application can use, regardless of whether it is built with Angular, React, Vue, or plain HTML. The Angular infrastructure—change detection, dependency injection, template rendering—travels with the element, so the consumer needs no knowledge of Angular to use it .

This capability solves a specific organizational problem. A team building an Angular application may also need to provide embeddable widgets: a search form, a dashboard panel, a configuration interface. Without Angular Elements, embedding those widgets in a non-Angular CMS, a legacy application, or a partner’s site would require rebuilding them in the host technology. Angular Elements makes the Angular component itself the deliverable, packaged as a standard HTML element that any page can instantiate with a tag .

The mechanism is the createCustomElement() function from @angular/elements, which converts an Angular component class into a class the browser can register as a custom element. Once registered, the element bootstraps itself when added to the DOM and destroys itself when removed, with Angular handling the lifecycle transparently .

Key point: createCustomElement() converts an Angular component into a custom element class. Register it with customElements.define(), and the component becomes a standard HTML tag. Inputs map to attributes, outputs map to Custom Events, and the element is self-bootstrapping.


Why Angular Elements exists

The framework-agnostic distribution problem. Angular components are not usable outside an Angular application without Angular Elements. The component depends on Angular’s dependency injection, change detection, and template compilation. Angular Elements bundles that infrastructure into the custom element, so the component runs anywhere the element is registered, without the host page needing Angular .

The multi-team widget problem. An organization with multiple teams and multiple applications needs a way to share UI components without imposing a framework choice. Angular Elements provides a neutral packaging format: the component is delivered as a custom element, and the consuming team uses it like any other HTML tag. The Angular team describes this as extending the reach of Angular code to “widgets and components that can be embedded in applications by other developers, on other teams, and even using other technologies” .

The micro-frontend integration problem. Micro-frontend architectures often need a framework-agnostic composition mechanism. Angular Elements provides one: the shell loads a bundle, the bundle registers custom element tags, and the shell uses those tags in its own markup. The shell does not need to know that the component was built with Angular .

The incremental migration problem. A legacy application can be migrated to Angular one component at a time. Each migrated component is packaged as a custom element and embedded in the legacy page. The legacy application continues to function, and the Angular component runs alongside it without requiring a full rewrite .

The bootstrap problem. A component added to a page needs to be instantiated, have its dependencies injected, run change detection, and be cleaned up when removed. Doing this manually with dynamic component loading requires the caller to attach the view to ApplicationRef, set inputs, subscribe to outputs, and detach the view. Angular Elements handles all of this automatically: the element bootstraps when connected and destroys when disconnected .


a. Creating a custom element

The process has three steps: create the component, convert it with createCustomElement(), and register it with customElements.define(). The @angular/elements package must be installed first .

npm install @angular/elements

The conversion function takes the component class and an injector, and returns a class that extends HTMLElement:

import { createCustomElement } from '@angular/elements';
import { Injector } from '@angular/core';
import { PopupComponent } from './popup.component';

export class AppModule {
  constructor(private injector: Injector) {
    const element = createCustomElement(PopupComponent, {
      injector: this.injector,
    });
    customElements.define('my-popup', element);
  }
}

With standalone components and the modern createApplication API, the pattern is similar:

import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { CommentsComponent } from './comments.component';

(async () => {
  const app = await createApplication({ providers: [] });
  const commentsElement = createCustomElement(CommentsComponent, {
    injector: app.injector,
  });
  customElements.define('comments-widget', commentsElement);
})();

The injector provides the component access to the application’s services and providers. The customElements.define() call registers the tag name with the browser’s CustomElementRegistry. When the browser encounters <my-popup> in the markup, it instantiates the component .

Avoid using the component’s selector as the custom element tag. Angular creates two component instances for a single DOM element when the selector and tag match: one from the Angular component and one from the custom element. Use a distinct tag name .


b. Mapping inputs and outputs

The custom element bridges Angular’s component interface and standard DOM APIs. Component @Input properties become HTML attributes, and @Output properties become Custom Events .

Inputs to attributes. Angular parses the component’s input properties and defines corresponding attributes. Because HTML attributes are case-insensitive, the names are transformed to dash-separated lowercase. An input named myInputProp becomes the attribute my-input-prop .

@Component({ selector: 'my-popup', template: '...' })
export class PopupComponent {
  @Input() message = '';
}
<my-popup message="Use Angular!"></my-popup>

For complex objects that cannot be represented as attributes, the consumer assigns them as JavaScript properties on the element:

<pathway-browser></pathway-browser>
<script>
  const el = document.querySelector('pathway-browser');
  el.pathwayId = 'R-HSA-123456';
</script>

Outputs to Custom Events. Component outputs are dispatched as HTML Custom Events. The event name matches the output name, and the emitted data is available on the event’s detail property .

@Component({ selector: 'my-popup', template: '...' })
export class PopupComponent {
  @Output() valueChanged = new EventEmitter<string>();
}
<my-popup></my-popup>
<script>
  document.querySelector('my-popup').addEventListener('valueChanged', (event) => {
    console.log(event.detail);
  });
</script>

An alias on the output changes the event name: @Output('myClick') clicks = new EventEmitter<string>() dispatches events named myClick .


c. Standalone components and modern configuration

Angular 17 and later use standalone components by default, and the Angular Elements workflow reflects this. The createApplication function bootstraps the Angular platform without a root component, and createCustomElement takes the standalone component directly .

// elements.ts
import { createCustomElement } from '@angular/elements';
import { createApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { appConfig } from './app.config';

createApplication(appConfig).then((appRef) => {
  const element = createCustomElement(AppComponent, {
    injector: appRef.injector,
  });
  customElements.define('pathway-browser', element);
});

The build uses --output-hashing none to produce filenames that are predictable for inclusion in a host page:

ng build --configuration production --output-hashing none

The host page includes the generated scripts and uses the custom element tag:

<!DOCTYPE html>
<html>
<head>
  <script src="dist/main.js" type="module"></script>
</head>
<body>
  <pathway-browser pathway-id="R-HSA-123456"></pathway-browser>
</body>
</html>

This pattern allows a standalone component to function both as part of an Angular application and as a standalone web component, with all standalone features—signals, inputs, outputs—preserved .


d. Zone.js and zoneless configuration

By default, Angular Elements relies on Zone.js for change detection. When the element is used in a non-Angular host, Zone.js monkey-patches browser APIs to detect asynchronous operations and trigger change detection. This can cause conflicts with other frameworks that also patch the same APIs, and it increases the bundle size of every element .

Modern Angular supports zoneless change detection, which is the recommended configuration for Angular Elements. Zoneless change detection removes the Zone.js dependency, eliminating the API conflicts and reducing the bundle size. However, it requires explicit configuration: the component must use signals or manually trigger change detection for updates .

The guidance is to ensure all Angular applications on the same page use zoneless mode if any of them do. Mixing zoneless and Zone.js elements on the same page can cause inconsistent change detection behavior .


Complete Example Session

# ============================================
# PART 1: INSTALL ANGULAR ELEMENTS
# ============================================
npm install @angular/elements
// ============================================
// PART 2: CREATE A COMPONENT
// ============================================
// popup.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'my-popup',
  standalone: true,
  template: `
    <span>Popup: {{ message }}</span>
    <button type="button" (click)="closed.emit()">✖</button>
  `,
})
export class PopupComponent {
  @Input() message = '';
  @Output() closed = new EventEmitter<void>();
}
// ============================================
// PART 3: CONVERT TO CUSTOM ELEMENT
// ============================================
// main.ts
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { PopupComponent } from './popup.component';

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

  const popupElement = createCustomElement(PopupComponent, {
    injector: app.injector,
  });

  customElements.define('my-popup', popupElement);
})();
<!-- ============================================
PART 4: USE THE ELEMENT IN HTML
============================================ -->
<my-popup message="Use Angular!"></my-popup>
// ============================================
// PART 5: LISTEN TO OUTPUT EVENTS
// ============================================
const popup = document.querySelector('my-popup');
popup.addEventListener('closed', () => {
  console.log('Popup closed');
});
<!-- ============================================
PART 6: PASS COMPLEX DATA VIA PROPERTY
============================================ -->
<pathway-browser id="browser"></pathway-browser>
<script>
  const browser = document.getElementById('browser');
  browser.pathwayId = 'R-HSA-123456';
</script>
// ============================================
// PART 7: BUILD FOR PRODUCTION
// ============================================
// No output hashing for predictable filenames
ng build --configuration production --output-hashing none
<!-- ============================================
PART 8: INCLUDE IN A HOST PAGE
============================================ -->
<!DOCTYPE html>
<html>
<head>
  <script src="dist/main.js" type="module"></script>
</head>
<body>
  <my-popup message="Hello from the host page"></my-popup>
</body>
</html>
// ============================================
// PART 9: AVOID SELECTOR CONFLICT
// ============================================
// Component selector: 'my-popup'
// Custom element tag: 'app-popup' (different)
customElements.define('app-popup', popupElement);
// ============================================
// PART 10: ZONELESS CONFIGURATION
// ============================================
// Use zoneless change detection for elements
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

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

These ten parts cover installation, component creation, conversion, HTML usage, event listening, property assignment, production build, host page integration, selector conflict avoidance, and zoneless configuration.


Quick Reference

Essential API

APIPurpose
createCustomElement()Convert component to custom element class
customElements.define()Register tag name with browser
NgElementType for the custom element instance
NgElementConfigConfiguration for createCustomElement
CUSTOM_ELEMENTS_SCHEMAAllow custom element tags in Angular templates

Input/Output Mapping

AngularDOM
@Input() nameAttribute name
@Input('myInputProp')Attribute my-input-prop
@Output() changedEvent changed
@Output('myClick')Event myClick
Emitted valueevent.detail

Configuration Options

OptionPurpose
injectorProvides dependency injection context
strategyFactoryCustom change detection strategy
exposedMethodsComponent methods exposed on element

Best Practices

PracticeReason
Avoid selector as tag namePrevents double instantiation
Use zonelessAvoids Zone.js conflicts
Build without output hashingPredictable filenames for host pages
Use aliases for inputsHTML attributes cannot have uppercase
Exclude from compilationRemove from angular.json build if not used in app

Best Practices

✅ Do This:

const element = createCustomElement(PopupComponent, { injector });
customElements.define('app-popup', element);  // Different from selector
<app-popup message="Hello"></app-popup>
document.querySelector('app-popup').addEventListener('closed', handler);

❌ Don’t Do This:

customElements.define('my-popup', element);  // ❌ Same as selector
<my-popup></my-popup>  <!-- ❌ Creates two instances -->

Common Pitfalls

PitfallWhy It HappensFix
Element not renderingTag not registeredCall customElements.define()
Input not updatingAttribute name case mismatchUse dash-separated lowercase
Output not firingListening on wrong event nameMatch event name exactly
Zone.js conflictHost page uses another frameworkUse zoneless change detection
Two instances createdSelector used as tag nameUse a distinct tag name
Bundle size largeZone.js and Angular runtime includedUse zoneless; consider tree-shaking

Real-World Examples

1. Popup Widget

@Component({ selector: 'my-popup', template: '...' })
export class PopupComponent {
  @Input() message = '';
  @Output() closed = new EventEmitter<void>();
}
const element = createCustomElement(PopupComponent, { injector });
customElements.define('app-popup', element);

2. Search Form Embedded in a CMS

<my-search-form default-query="angular"></my-search-form>

3. Standalone Component as Web Component

createApplication(appConfig).then((appRef) => {
  const element = createCustomElement(AppComponent, {
    injector: appRef.injector,
  });
  customElements.define('pathway-browser', element);
});

4. Complex Input via Property

<pathway-browser></pathway-browser>
<script>
  document.querySelector('pathway-browser').pathwayId = 'R-HSA-123456';
</script>

5. Event with Data

@Output() countChange = new EventEmitter<number>();
element.addEventListener('countChange', (e) => {
  console.log(e.detail);  // The emitted number
});

6. Zoneless Element

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

7. Build Without Hashing

ng build --configuration production --output-hashing none

8. Micro-Frontend Registration

const elements = [ButtonComponent, CardComponent, InputComponent];
elements.forEach(comp => {
  const el = createCustomElement(comp, { injector });
  customElements.define(comp.selector, el);
});

9. Exclude from App Compilation

// angular.json: remove from app's build if not used in app

10. CUSTOM_ELEMENTS_SCHEMA in Templates

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

@Component({
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class HostComponent {}

Visual

Custom Element Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  createCustomElement(PopupComponent, { injector })           │
│       │                                                      │
│       ▼                                                      │
│  Class extends HTMLElement                                   │
│       │                                                      │
│       ▼                                                      │
│  customElements.define('app-popup', Class)                   │
│       │                                                      │
│       ▼                                                      │
│  Browser sees <app-popup>                                    │
│       │                                                      │
│       ▼                                                      │
│  connectedCallback() → bootstrap Angular component           │
│       │                                                      │
│       ▼                                                      │
│  Component renders with change detection                     │
│       │                                                      │
│       ▼                                                      │
│  disconnectedCallback() → destroy component                  │
└──────────────────────────────────────────────────────────────┘

Input/Output Mapping

┌──────────────────────────────────────────────────────────────┐
│  ANGULAR COMPONENT          DOM CUSTOM ELEMENT               │
│                                                              │
│  @Input() name              <app-popup name="Alice">         │
│  @Input('myInputProp')      <app-popup my-input-prop="...">  │
│                                                              │
│  @Output() changed          element.addEventListener(        │
│  @Output('myClick')           'changed', handler)            │
│  emitter.emit(data)         event.detail === data            │
└──────────────────────────────────────────────────────────────┘

Selector Conflict

┌──────────────────────────────────────────────────────────────┐
│  CORRECT                        WRONG                        │
│                                                              │
│  Component selector: my-popup   Component selector: my-popup │
│  Custom tag: app-popup          Custom tag: my-popup         │
│                                                              │
│  One instance                   Two instances                │
│  Angular + custom element       Angular + custom element     │
│  work together                  conflict                     │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Angular ElementsPackage Angular components as custom elements
Package@angular/elements
Conversion APIcreateCustomElement(component, config)
RegistrationcustomElements.define('tag-name', elementClass)
InputsMapped to dash-case attributes
OutputsDispatched as Custom Events
Event dataevent.detail
BootstrapAutomatic on DOM connection
TeardownAutomatic on DOM disconnection
Zone.jsOptional; zoneless recommended
Selector conflictAvoid using selector as tag name

Key takeaways:

  • Angular Elements packages Angular components as custom elements. The component becomes a standard HTML tag that any framework can use. The Angular infrastructure travels with the element, so the host needs no Angular knowledge .
  • createCustomElement() does the conversion. It takes the component class and an injector, and returns a class the browser can register with customElements.define() .
  • Inputs become attributes; outputs become Custom Events. Input names are transformed to dash-separated lowercase. Output events carry their data on event.detail .
  • Avoid using the component selector as the custom element tag. Matching names cause Angular to create two component instances for one DOM element .
  • Standalone components work with the modern createApplication API. The pattern bootstraps the platform without a root component, then registers the custom element with the platform’s injector .
  • Zone.js is the traditional change detection mechanism but is problematic for elements. Zoneless change detection is recommended for Angular Elements because it avoids conflicts with other frameworks and reduces bundle size .
  • Complex objects are passed as properties, not attributes. HTML attributes are strings. For objects and arrays, assign the value to a JavaScript property on the element instance .

Remember: Angular Elements is the bridge between Angular’s component model and the browser’s custom element standard. It packages a component with its dependencies and Angular infrastructure into a self-bootstrapping HTML element. The component’s inputs and outputs map to standard DOM attributes and events, so the consumer interacts with it as they would with any other element. The primary use cases are sharing components across teams and frameworks, embedding widgets in non-Angular applications, and incremental migration of legacy applications. The primary concerns are Zone.js conflicts and bundle size, both addressed by zoneless change detection. The selector conflict—using the component’s selector as the custom element tag—is the most common mistake and produces two instances for one element. When used deliberately, Angular Elements extends the reach of Angular code beyond the Angular application boundary.

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!