DEV Community

Cover image for Web Components in Angular: The Good and Bad of Web Components
bytebantz
bytebantz

Posted on

Web Components in Angular: The Good and Bad of Web Components

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 });
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. One instance using the regular Angular component system.
  2. 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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Example 2: With alias

If you use an alias with the @Output():

@Output('myClick') clicks = new EventEmitter();
Enter fullscreen mode Exit fullscreen mode

Then, the custom event name becomes myClick instead of clicks. You can listen to it like this:

<my-element (myClick)="handleClick($event)"></my-element>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Events do not bubble past the shadow root.
  2. 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:

  1. Create the component.
  2. Add it to the DOM manually (the HTML structure).
  3. Manually set up dependency injection (services, data) and change detection (keep the view updated).
  4. 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:

  1. Create the Angular component as usual (with your template and logic).
  2. Convert it to a custom element using Angular’s built-in tools.
  3. 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
Enter fullscreen mode Exit fullscreen mode

Step 2: Install the @angular/elements package:

Run the following command in your terminal:

npm install @angular/elements --save
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a Dynamic Component

Run the following command in your terminal:

ng generate component dynamic
Enter fullscreen mode Exit fullscreen mode

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!');
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Generate a new Angular element (Web Component)

ng generate component my-button
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }>;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)