Normally, Angular components work only inside Angular applications. But Angular createCustomElement() allows you to turn an Angular component into a custom HTML element (also called a web component) that can be used anywhere, even outside an Angular app while still keeping all of Angular’s features.
What Are Web Components?
Web components are a set of standards that allow developers to create custom HTML elements using JavaScript
For example, in normal HTML, you have built-in elements like < button > or < input >. But what if you want a new type of element, like < cool-button >? You can create it using JavaScript.
Web Components allow you to mix and match different UI components from various sources. However, just because you can doesn’t mean you should.
If one Web Component uses React, another Vue, and another Angular, the browser has to load separate runtimes, which:
❌Makes the page slower (more JavaScript to download and execute).
❌Increases file size (bad for performance).
❌Creates maintenance headaches (hard to manage different technologies).
Advantages of using Web Components:
✅Reusability: Custom elements allow for code sharing across different projects.
✅Flexibility: They can be used in any web application regardless of the framework.
✅Easy Integration: They can be seamlessly added to existing web applications to introduce new functionality.
✅Future-Proofing — Based on web standards, reducing framework dependency.
Challenges involved in using Web Components:
❌Browser Support: Not all browsers support web components, which can limit usage in older browsers.
❌Complexity: Creating custom elements can be challenging.
❌Debugging: Debugging custom elements, especially in complex applications, can be difficult.
Angular provides support for Web Components through Angular Elements, allowing developers to create Web Components from Angular components.
How it works
The createCustomElement() function takes an Angular component and turns it into a JavaScript class
const coolButtonElement = createCustomElement(CoolButtonComponent, { injector });
This JavaScript class can then be registered with the browser as a custom element using the browser’s built-in customElements.define() function.
customElements.define('cool-button', coolButtonElement);
The browser keeps track of all custom elements using something called the CustomElementRegistry, which links each tag (like < cool-button >) to its JavaScript class (CoolButton).
⚠️ Important Note
Avoid using your Angular component’s selector as the Web Component tag name.
For example, if your component’s selector is app-alert, and you use it as the custom element tag < app-alert >< /app-alert >, it can cause issues.
Why?
Angular might try to create two instances of the component for the same DOM element:
- One instance using the regular Angular component system.
- Another instance using the custom element registration.
Once you register this class with the browser’s custom element registry, it becomes a special HTML tag that you can use just like any built-in HTML element (e.g., < cool-button >< /cool-button >).
Handling Inputs & Outputs in Web Components
Handling Inputs
When you convert an Angular component into a custom element, the process automatically handles the input properties and converts them into attributes for the custom element.
Custom elements require attributes to use dash-separated lowercase names, and they don’t recognize camelCase which Angular uses for property names.
Angular automatically changes the input property names from camelCase (like myInputProp) to dash-separated lowercase (like my-input-prop) so that it fits the custom element requirements.
Handling Outputs
Angular automatically converts output events to Custom Events and uses either the original output name or the alias you provide as the event name for the custom element.
Example 1: No alias
If you have a component with this output:
@Output() valueChanged = new EventEmitter();
When the event valueChanged is triggered, it becomes a Custom Event in the DOM, and you can listen to it like this in HTML:
<my-element (valueChanged)="handleValueChanged($event)"></my-element>
Example 2: With alias
If you use an alias with the @Output():
@Output('myClick') clicks = new EventEmitter();
Then, the custom event name becomes myClick instead of clicks. You can listen to it like this:
<my-element (myClick)="handleClick($event)"></my-element>
Shadow DOM and Encapsulation
Web Components are rendered in the shadow DOM
Key Terms
Shadow Host 🏠: The regular DOM element to which a shadow DOM is attached
Shadow Tree 🌳: The DOM structure inside the shadow DOM. This tree is encapsulated, meaning styles and scripts from the main document won’t affect it unless explicitly allowed.
Shadow Boundary ✋: The invisible boundary separating the shadow DOM from the main DOM. Anything outside this boundary can’t directly style or interact with shadow DOM elements
Shadow Root 🌱: The root of the shadow tree. It’s where all shadow content starts.
Shadow DOM is a way to encapsulate styles and behavior within a web component. This means that elements inside a Shadow DOM behave as if they are in a separate, isolated world. The styles outside don’t affect them, and their styles don’t affect the rest of the page.
Why is this useful?
✅It prevents conflicts between styles and scripts from different parts of a website.
✅It allows developers to create reusable components without worrying about breaking the rest of the page.
But This Encapsulation Also Brings Challenges!
Because the Shadow DOM is isolated, certain normal browser behaviors don’t work as expected. Some events don’t bubble out of the Shadow DOM, certain form elements don’t behave normally, and even CSS has limitations.
What Are the Key Challenges?
1. Events Might Not Bubble
Normally, when you click an element, the event “bubbles up” to parent elements.
But inside the Shadow DOM, some events (like focusin) stop at the Shadow boundary and don’t continue to the outer document.
2. Some Events Behave Differently
Events like focusin and mouseenter don’t behave as expected because the Shadow DOM treats the entire component as a single unit.
A child element inside the Shadow DOM might gain focus, but the event reports that the focus is on the whole component instead.
3. CSS Encapsulation
Styles inside a Shadow DOM don’t affect elements outside it, and vice versa.
This is great for preventing unwanted style conflicts but can cause issues if you need to apply global styles to Shadow DOM elements.
Web Components and SSR
Web Components rely on browser APIs like customElements.define(), shadow DOM, and HTML templates.
These APIs don’t exist on the server, which means Web Components don’t naturally work in a server-side rendering (SSR) environment.
Developers need to create extra wrappers or tricks to make Web Components generate server-side HTML before hydration (the process of making server-rendered HTML interactive).
This adds complexity and performance overhead.
Shadow DOM and Hydration Issues
Since Web Components rely on Shadow DOM, they don’t naturally fit into modern hydration strategies.
For example, Incremental hydration depends on event delegation, meaning Angular places global event listeners on the
or a parent component to capture interactions before hydrating a component.However, Shadow DOM blocks event delegation because:
- Events do not bubble past the shadow root.
- Global event listeners in Angular cannot detect interactions inside a Web Component using Shadow DOM.
What this means:
If a Web Component inside an SSR-rendered Angular app is not hydrated, clicks inside it won’t trigger Angular’s hydration logic.
Angular will never detect interactions inside a Shadow DOM unless explicitly re-dispatched.
Web Components and Angular Routing
When trying to host a second Angular app as a Web Component within another Angular application, routing often doesn’t work for the hosted app because each Angular application typically has its own isolated router instance; essentially, the parent application’s router doesn’t recognize routes defined within the child web component
Web Components Use Cases
1️⃣ Simplifying Dynamic Component Loading
Previously, if you wanted to add a component to a page dynamically (meaning you load it at runtime, not when the page is initially loaded), you had to do things like:
- Create the component.
- Add it to the DOM manually (the HTML structure).
- Manually set up dependency injection (services, data) and change detection (keep the view updated).
- Manually wire up events (like clicking buttons, etc.). This required a lot of code and was pretty complex.
With Custom Elements:
Using Angular Custom Elements makes this process much simpler. Here’s how:
- Create the Angular component as usual (with your template and logic).
- Convert it to a custom element using Angular’s built-in tools.
- Use it in your HTML as if it were a regular HTML element (like < app-dynamic-component >< /app-dynamic-component >), and Angular takes care of all the setup, dependency injection, and event handling for you.
This results in a simplified and streamlined process for dynamically loading components, reducing the amount of code you need to write and making your app easier to maintain.
Example
Step 1: Create a New Angular Project
Run the following command in your terminal:
ng new web-components-demo
Step 2: Install the @angular/elements package:
Run the following command in your terminal:
npm install @angular/elements --save
Step 3: Create a Dynamic Component
Run the following command in your terminal:
ng generate component dynamic
Edit the dynamic.component.ts file to include some basic logic:
import { Component, input } from '@angular/core';
@Component({
selector: 'app-dynamic',
imports: [],
template: `<div>{{ text() }}</div>`,
})
export class DynamicComponent {
text = input<string>('Default dynamic message!');
Step 4: Register the Web Component
In your main.ts, register the DynamicComponent as a web component using Angular Elements:
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import { createCustomElement } from '@angular/elements';
import { DynamicComponent } from './app/dynamic/dynamic.component';
// Bootstrap the application
bootstrapApplication(AppComponent, appConfig)
.then((app) => {
const dynamicElement = createCustomElement(DynamicComponent, {
injector: app.injector
});
customElements.define('dynamic-component', dynamicElement); // Register the web component
})
.catch((err) => console.error(err));
Step 5: Dynamically Load the Web Component
In your app.component.ts, create and insert the web component directly into the DOM:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<button (click)="createComponent()">Create Dynamic Component</button>
<button (click)="destroyComponent()">Destroy Dynamic Component</button>
<div id="container"></div>
`,
styleUrls: ['./app.component.css'],
})
export class AppComponent {
createComponent() {
const dynamicComponent = document.createElement('dynamic-component'); // Use the registered tag name
dynamicComponent.setAttribute(
'text',
'Hello, this is a dynamic web component!'
);
document.querySelector('#container')?.appendChild(dynamicComponent);
}
destroyComponent() {
const container = document.querySelector('#container');
if (container) {
container.innerHTML = ''; // Clear the container to remove all dynamic components
}
}
}
Shared Design System
Web Components are great when you need reusable UI elements that work across different frameworks or projects.
Imagine you’re building a large-scale application that has multiple teams working on different sections of the UI. One team uses Angular, another uses React, and a third one uses Vue.
Instead of each team recreating buttons, modals, and form elements in their own framework, you can build a set of Web Components that work everywhere.
How This Helps
✅ Framework-Agnostic: A Web Component like can be used in React, Vue, Angular, or even plain HTML.
✅ Single Source of Truth: Instead of maintaining separate versions of the same button in different frameworks, you maintain one component.
✅ Consistent UI: The design stays the same no matter where it’s used.
Example
Step 1: Create a new Angular project
Run the following command:
ng new shared-design-system
Step 2: Generate a new Angular element (Web Component)
ng generate component my-button
Step 3: Modify my-button.component.ts
import { Component, input } from '@angular/core';
@Component({
selector: 'app-my-button',
imports: [],
template: `<button class="my-button">{{ label() }}</button>`,
styles: [
`.my-button {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}`
]
})
export class MyButtonComponent {
label = input<string>('Click Me');
}
Step 4: Convert it into a Web Component
Modify main.ts:
import { createApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { createCustomElement } from '@angular/elements';
import { MyButtonComponent } from './app/my-button/my-button.component';
// Bootstrap the application
createApplication(appConfig)
.then((app) => {
const myButtonElement = createCustomElement(MyButtonComponent, {
injector: app.injector
});
customElements.define('my-button', myButtonElement); // Register the web component
})
.catch((err) => console.error(err));
The key reason createApplication is better for pure Web Components is that it does not automatically render a root component in Angular, unlike bootstrapApplication. Instead, it only initializes the Angular injector and allows you to register custom elements manually.
Step 5: Build the Angular Web Component
Run:
ng build --output-hashing=none
Step 6: Test the Web Component
To support older browsers, polyfills may be needed to provide support for web components.
If you import polyfills in main.ts, they become part of your main JavaScript bundle. This is fine if you’re only using one Angular web component.
If you embed multiple custom elements (Angular web components) on a page, each one might load the same polyfills multiple times. This can slow down your app or cause errors.
Instead of importing polyfills inside main.ts, you can load them separately using a < script > tag in your HTML. This way, they are loaded only once for all components.
Create a simple index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Web Component Demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<my-button></my-button>
<script src="polyfills.js" type="module"></script>
<script src="main.js" type="module"></script>
</body>
</html>
How to make your custom elements type-safe
In standard DOM methods like document.createElement() or document.querySelector(), TypeScript infers the type of the element returned based on the argument you provide.
For example:
document.createElement(‘a’) returns an HTMLAnchorElement with specific properties like href.
document.createElement(‘div’) returns an HTMLDivElement, which doesn’t have an href property.
However, when using custom elements, TypeScript can’t automatically infer the type because it doesn’t know the specific properties of that element.
It just thinks it’s a basic HTMLElement (a generic HTML element). For example, it doesn’t know your my-dialog element has a content property.
You have two ways to get TypeScript to understand the properties of your custom elements:
1. Casting the Type Manually
When you create the custom element, you can cast it to a more specific type that knows about the properties it has.
For example:
const dialog = document.createElement('my-dialog') as NgElement & WithProperties<{ content: string }>;
dialog.content = 'Hello, world!'; // Now TypeScript knows 'content' is a string
This way, TypeScript will now know that dialog.content should be a string and give you features like type checking and autocomplete.
The downside:
You have to write this casting (as NgElement & WithProperties) every time you create a custom element.
2. Augmenting TypeScript’s Default Knowledge of Elements
Instead of casting every time, you can tell TypeScript about your custom elements once by adding a special definition.
This is done by extending the HTMLElementTagNameMap which is how TypeScript knows what properties different HTML tags should have.
For example, you can add this globally:
declare global {
interface HTMLElementTagNameMap {
'my-dialog': NgElement & WithProperties<{ content: string }>;
}
}
Now, you don’t need to cast the element manually every time:
const dialog = document.createElement('my-dialog');
dialog.content = 'Hello, world!'; // TypeScript knows 'content' is a string automatically
Using augmentation is simpler and avoids repetitive code. It ensures TypeScript automatically knows the properties of your custom elements, making your code cleaner.
Conclusion
Web Components provide a powerful way to create reusable UI elements that can work across different frameworks. By using Angular Elements, you can easily create and manage these components while maintaining the benefits of Angular’s architecture.
Check out my deep dives on → Gumroad
Top comments (0)