Building and consuming Angular Elements as Web Components
In this article we'll explore building and bundling Web Components in Angular, approaches to consume Elements that has been enabled by Ivy and some limitations.

In the previous article we looked at Web Components in general and explored where Angular Elements comes into play. But creating and using Web Components is only one part of the story. Another key aspect is building and consuming them.
The web can be very sensitive to bundle size, with every byte leading to longer load times and the potential loss of users. As such we need to make sure we are building/bundling Web Components so they are as small as possible.
This article will look at how we can get the bundle size as small as possible; exploring both the current state of Angular and the prospects that Ivy has brought to the table. Here's the structure of the article:
- Building and Bundling Web Components in Angular.
- Other approaches available to us using Ivy.
- Some limitations and possible solutions..
- Some key tools.
Let's get started.
Building Angular elements
When it comes to building and bundling web components we have a few options. We can either build all lib components together with Angular runtime and ship it as a single bundle or we can build runtime and lib components as standalone packages. In this case the runtime and other dependencies will be built as so-called externals.

Each approach has its advantages and drawbacks. Let's explore them.
Packaging runtime with lib components
Currently @angular/elements
takes your Component and creates a Custom Element for you. This is great but, even with --prod
build, you will notice that the bundle size is rather large. This is because it bundles in the runtime and all the libraries it needs.
This isn't really great because the bundle size and time to interactive is pretty big. On the other hand, this approach enables tree shaking as Angular knows during the build which components and the framework features are not being used.
Building runtime as externals
The idea is we split the shared libraries from the Web Components bundle, leaving only the runtime code that is required for the component to run. Applications that use the Web Components are responsible for providing these shared dependencies.
In webpack this has the name “externals” and can be configured via the webpack config file:
The externals configuration option provides a way of excluding dependencies from the output bundles. Instead, the created bundle relies on that dependency to be present in the consumer's (any end-user application) environment. This feature is typically most useful to library developers, however there are a variety of applications for it.
If you're using Angular CLI, the best option is to use Manfred's
ngx-build-plus library to add externals to Webpack config.
If shared dependencies are built and loaded to the browser window as a standalone package you can then lazy load individual components on demand. Shipping externals separately from the Web Component also gives the added benefit that runtime can be cached on the server/cdn or by a browser.
Using externals can greatly reduce the bundle size. It does however, require the application consuming these Web Components to import the shared dependencies. This makes the Web Components deviate from the plug and play idea. This may be deemed acceptable depending on the use case.
Consuming Angular Elements
Ivy has officially landed! It is an exciting time to think about the possibilities it brings in regards to Web Components and Custom Elements.
Angular has had @angular/elements
for a while. There are some problems however. A big one is the bundle size. This is where some of the private APIs from Angular’s can really help out. Let's explore them and see what we need to keep our eyes on.
What goes next is basically my discoveries and learning of Angular under the hood. It is to highlight some challenges and more importantly start to understand what the future holds in this space. A lot of what is explored here are the private APIs and are therefore subject to change. This is not a how to guide; nothing expressed herein is recommended for production builds.
All code can be found in this Angular workspace on github.
OK, so let’s take a look at the two ways we can consume and create a Web Component with Angular Ivy and some problems that arise.
The first approach is to manually create a Custom Element and use the private Angular API function ɵrenderComponent
to attach an Angular Component to the DOM. As discussed in the previous article we will have to manually set up things like custom events, attributes, properties and the reflection of properties to attributes.
import { ɵdetectChanges, ɵrenderComponent } from '@angular/core';
import { EventManager, ɵDomEventsPlugin, ɵDomSharedStylesHost, ɵDomRendererFactory2 } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { CustomDomRendererFactory2 } from './custom/customDomRendererFactory2';
export class HelloWorldElement extends HTMLElement {
static get observedAttributes() {
return ['title'] as Array<keyof AppComponent>;
}
private component: AppComponent;
constructor() {
super();
this.component = ɵrenderComponent(AppComponent, {
host: this,
rendererFactory: new CustomDomRendererFactory2(
new EventManager([new ɵDomEventsPlugin(document)], null),
new ɵDomSharedStylesHost(document),
null
)
});
}
attributeChangedCallback(name: keyof AppComponent, oldValue: any, newValue: any) {
switch (name) {
case 'title':
this.component.title = newValue;
break;
}
}
get title(): string {
return this.component.title;
}
set title(value: string) {
this.component.title = value;
ɵdetectChanges(this.component);
}
}
The second approach is to use @angular/elements
method createCustomElement()
. Although this handles all the manual work we had to do in the first approach, it does not use the power of the Ivy render. We can use Angular Dependency Injection to override providers to make it use the Ivy renderer.
import {
ApplicationRef,
ComponentFactory,
ComponentFactoryResolver,
Injector,
RendererFactory2,
Type,
ViewRef,
ɵNG_COMP_DEF,
ɵRender3ComponentFactory
} from '@angular/core';
import { createCustomElement, NgElementConfig, NgElementConstructor } from '@angular/elements';
import { EventManager, ɵDomEventsPlugin, ɵDomRendererFactory2, ɵDomSharedStylesHost } from '@angular/platform-browser';
class IvyComponentFactoryResolver extends ComponentFactoryResolver {
resolveComponentFactory<T>(component: Type<T>): ComponentFactory<T> {
return new ɵRender3ComponentFactory(component[ɵNG_COMP_DEF]);
}
}
class NoopApplicationRef {
attachView(_viewRef: ViewRef): void {}
}
export function createCustomIvyElement<P>(component: Type<any>, config2?: NgElementConfig): NgElementConstructor<P> {
const config = { injector: Injector.NULL };
config.injector = Injector.create({
name: 'IvyElmentInjector',
parent: config.injector,
providers: [
{
provide: ApplicationRef,
useFactory: () => new NoopApplicationRef()
},
{
provide: ComponentFactoryResolver,
useFactory: () => new IvyComponentFactoryResolver()
},
{
provide: RendererFactory2,
useFactory: () =>
new ɵDomRendererFactory2(
new EventManager([new ɵDomEventsPlugin(document)], null),
new ɵDomSharedStylesHost(document),
null
)
}
]
});
return createCustomElement(component, config);
}
Approach 1: Custom HTMLElement
Here we extend the HTMLElement
class and in the constructor use power of Ivy to render a component:
export class HelloWorldElement extends HTMLElement {
...
constructor() {
super();
const host = document.createElement('app-component');
this.component = ɵrenderComponent(AppComponent, { host: this });
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(host);
}
...
}
In your main.ts, we can now register this element with the browser using
customElements.define('h-w', HelloWorldElement);
When we serve this project we will notice that the Angular component is rendered on the screen but with no styles!!
Why?
Well first what does the ɵrenderComponent
do?
Bootstraps a Component into an existing host element and returns an instance of the component.
It does this through the use of a rendererFactory
. By default it uses the domRendererFactory3
:
export const domRendererFactory3: RendererFactory3 = {
createRenderer: (hostElement: RElement | null, rendererType: RendererType2 | null): Renderer3 => {
return getDocument();
}
};
getDocument(): Ivy calls this whenever it needs to access the `document` object.
As you can see this renderer just returns the document object and doesn’t think about styles.
So what do we do?
We can see that ɵrenderComponent
accepts a rendererFactory
, and lucky for us there is a private factory that fits the bill; the ɵDomRendererFactory2
:
this.component = ɵrenderComponent(AppComponent, {
host: this,
rendererFactory: new ɵDomRendererFactory2(
new EventManager([new ɵDomEventsPlugin(document)], null),
new ɵDomSharedStylesHost(document),
01
)
});
The ɵDomRendererFactory2
uses a switch statement to return a renderer based on the encapsulation type of the component we are trying to render.
The EventManger
with the ɵDomEventsPlugin
is to register DOM events and the ɵDomSharedStylesHost
is so we can share styles across components.
If we set the component to use viewEncapsulation.ShadowDom
, then the ɵDomRendererFactory2
will return the ShadowDomRenderer
.
The ShadowDomRenderer
will attach the ShadowDom for us, therefore we can remove that logic from our CustomElement class definition…neat!
We are set now right?
Well if you run the above you will get an error:
platform-browser.js:1343 Uncaught DOMException: Failed to execute ‘attachShadow’ on ‘Element’: Shadow root cannot be created on a host which already hosts a shadow tree.
Why?
The problem is that the ɵrenderComponent
uses the factory to create a new renderer quite a few times. Each time creating a new renderer.
When rendering a ViewEncapsulation.Emulated
component they have a guard against this and return the same renderer:
...
case ViewEncapsulation.Emulated: {
let renderer = this.rendererByCompId.get(type.id);
if (!renderer) {
renderer = new EmulatedEncapsulationDomRenderer2(this.eventManager, this.sharedStylesHost, type, this.appId);
this.rendererByCompId.set(type.id, renderer);
}
(<EmulatedEncapsulationDomRenderer2>renderer).applyToHost(element);
return renderer;
}
...
But this is missing from the ViewEncapsulation.Native
and ViewEncapsulation.ShadowDom
cases… So unfortunately for now you have manually add this same check.
...
case ViewEncapsulation.Native:
case ViewEncapsulation.ShadowDom: {
let renderer = this.rendererByCompId.get(type.id);
if (!renderer) {
renderer = new ShadowDomRenderer(this.eventManager, this.sharedStylesHost, element, type);
this.rendererByCompId.set(type.id, renderer);
}
return renderer;
}
...
See provided github for the full custom class.
With this done your Custom Element should render the root app component and any nested components you may have. All with styles!! Phew!
Approach 2: createCustomElement
@angular/elements
method createCustomElement()
abstracts the creation of the HTMLElement
class. Unfortunately it doesn't draw on the power of Ivy by default.
Lucky for us the same things done in Approach 1 can be done here to; just in a different way.
We know that createCustomElement()
from @angular/elements
uses Angular's dependency injection. As a result we can just provide the right classes in the injectors providers array:
export function createCustomIvyElement<P>(component: Type<any>, config2?: NgElementConfig): NgElementConstructor<P> {
const config = { injector: Injector.NULL };
config.injector = Injector.create({
name: 'IvyElmentInjector',
parent: config.injector,
providers: [
{
provide: ApplicationRef,
useFactory: () => new NoopApplicationRef()
},
{
provide: ComponentFactoryResolver,
useFactory: () => new IvyComponentFactoryResolver()
},
{
provide: RendererFactory2,
useFactory: () =>
new ɵDomRendererFactory2(
new EventManager([new ɵDomEventsPlugin(document)], null),
new ɵDomSharedStylesHost(document),
null
)
}
]
});
return createCustomElement(component, config);
}
TheIvyComponentFactoryResolver
returns theɵRender3ComponentFactory
Serving the app now will just work!
Why can we use the non-customized ɵDomRendererFactory2?
createCustomElement()
uses a componentFactory
vs the rendererFactory
(used by ɵrenderComponent
). The former checks if it is the root being created and only creates one ShadowDomRenderer
if it is.
It has been very interesting exploring what the differences between createCustomElement()
and ɵrenderComponent
are, and how we can use modify them to use Ivy and add styles.
There seems to be a little of a drift between the two when maybe there doesn’t have to be?
Approaches to synchronizing state with view in Angular elements
Let's now take a look at how to run change detection when using Angular elements. We'll answer the following question:
I want the great bundle size of the above but am using a third-party directive. It is using markForCheck()
to mark the component as dirty. But it never gets checked so values don’t update. How do I solve this?
Lucky there is a private API which can help ɵmarkDirty()
. The ɵmarkDirty
method (unlike markForCheck
) calls scheduleTick()
, which triggers change detection.
Great but how?
Angular creates a new ChangeDetectorRef
a.k.a ViewRef
for every componentView
. When we mark a view as dirty (using markForCheck
) it traverses up the view tree marking all components dirty. We can monkey patch this method and use the private API ɵmarkDirty()
.
In the root component we can do something like this!
...
export class AppComponent {
...
constructor(private cdr: ChangeDetectorRef) {
this.cdr.__proto__.markForCheck = () => ɵmarkDirty(this);
}
...
Now when a new ViewRef
is created for the third-party directive it will instantiate one that uses the private API ɵmarkDirty()
. Changes will now be reflected.
The Angular extras
In the Angular world there is some great tooling out there to help with building and consuming Web Components.
Ngx-build-plus
Ngx-build-plus is a great addition. It extends the Angular CLI's default build behavior. Here are some of the features of this library:
Single bundle outputs
Takes the multiple bundle output from Angular and creates a single bundle for easy consumption. This is a key build flag for using with @angular/elements.
Externals
Creates a partial webpack config, which is used in conjunction with Angular’s internal webpack config. This partial config uses webpack’s “externals” configuration option preventing the bundling of certain Angular specific imported packages and instead retrieves these external dependencies at runtime. (Ref above section that discusses externals)
Angular-extensions
This is a fantastic Angular Library that takes away a lot of the headaches of orchestrating when and how to load Web Components into your application. Supplied with a small amount of config, the library’s Angular directive will take care of adding the script tag and the actual Custom Element tag to the html. From dynamic loading and caching, to advanced template binding functionality, it is a great addition to the world of Web Components in Angular.
So that is all from me. I hope you have enjoyed looking at some of the private APIs and what Ivy can maybe bring us in the future for Custom Elements.