|$ curl https://forge-ai.dev/api/markdown?path=docs/html/web-components
$cat docs/web-components.md
updated This week·45 min read·published

Web Components

HTMLWeb ComponentsAdvancedAdvanced
Introduction

Web Components are a set of browser-native APIs that enable you to create reusable, encapsulated custom HTML elements. Unlike framework components (React, Vue, Angular), Web Components are framework-agnostic — they work across any JavaScript framework or no framework at all. They are part of the browser specification and require no external libraries.

The Web Components specification consists of three core technologies: Custom Elements for defining new HTML tags, Shadow DOM for encapsulating styles and markup, and HTML Templates (including <template> and <slot>) for declarative markup fragments. Together, they provide the same encapsulation and reusability benefits as framework components, but at the browser level.

Web Components are supported natively in all modern browsers (Chrome, Firefox, Safari, Edge). With lightweight polyfills, they work back to Internet Explorer 11. Major companies like Google, Microsoft, ING, GitHub, and Salesforce use Web Components in production for design systems, widget libraries, and cross-framework component sharing.

Three Pillars of Web Components

Web Components rest on three browser APIs that work independently but are designed to be composed together:

1. Custom Elements

The customElements.define() API lets you register a new HTML element with a custom tag name (must contain a hyphen). Your element can react to lifecycle events — creation, attribute changes, connection to the DOM, and removal. Custom Elements can extend native HTML elements (like <button> or <input>) using the extends keyword.

2. Shadow DOM

The Shadow DOM provides style and markup encapsulation. When you attach a shadow root to an element, its internal DOM tree is isolated from the main document. CSS scoped inside the shadow tree does not leak out, and external CSS does not penetrate in (unless you use ::part() or CSS custom properties). This eliminates style conflicts entirely.

3. HTML Templates & Slots

The <template> element holds HTML fragments that are parsed but not rendered. Clone its content with JavaScript to stamp out repeated structures. The <slot> element creates insertion points inside the Shadow DOM, allowing users of your component to project their own content into designated areas — this is how composition works in Web Components.

📝

note

You can use each pillar independently. A Custom Element does not require Shadow DOM, and <template> is useful even without Custom Elements. However, combining all three gives you the full power of framework-independent, encapsulated, composable components.
Custom Elements — Deep Dive

Custom Elements are defined by creating a class that extends HTMLElement (or a specific HTML element interface like HTMLButtonElement for customized built-in elements). The class defines the element's behavior, appearance, and lifecycle callbacks.

Lifecycle CallbackCalled When
constructor()Element is created (before being attached to DOM)
connectedCallback()Element is inserted into the DOM
disconnectedCallback()Element is removed from the DOM
attributeChangedCallback(name, oldValue, newValue)An observed attribute is added, changed, or removed
adoptedCallback()Element is moved to a new document
custom-element.js
JavaScript
1// Define a custom element class
2class MyElement extends HTMLElement {
3 constructor() {
4 super();
5 // Initialize state, set up event listeners
6 this._count = 0;
7 }
8
9 // Specify which attributes to observe
10 static get observedAttributes() {
11 return ['count', 'label'];
12 }
13
14 // Lifecycle: element inserted into DOM
15 connectedCallback() {
16 this.render();
17 }
18
19 // Lifecycle: element removed from DOM
20 disconnectedCallback() {
21 // Clean up: remove event listeners, timers
22 }
23
24 // Lifecycle: observed attribute changes
25 attributeChangedCallback(name, oldVal, newVal) {
26 if (oldVal === newVal) return;
27 if (name === 'count') this._count = parseInt(newVal) || 0;
28 if (name === 'label') this._label = newVal;
29 this.render();
30 }
31
32 render() {
33 this.textContent = `${this._label || 'Count'}: ${this._count}`;
34 }
35}
36
37// Register the element (tag MUST contain a hyphen)
38customElements.define('my-element', MyElement);

Using the custom element in HTML:

using-custom-element.html
HTML
1<!-- Use the custom element like any HTML tag -->
2<my-element count="5" label="Items"></my-element>
3
4<!-- Attribute changes trigger attributeChangedCallback -->
5<script>
6 const el = document.querySelector('my-element');
7 el.setAttribute('count', '10'); // triggers update
8 el.removeAttribute('label'); // triggers update
9</script>

best practice

The custom element tag name must contain a hyphen (my-element, app-header). This requirement ensures forward compatibility — the HTML specification guarantees no future native HTML element will contain a hyphen. Single-word names like <mytab> will throw an error.
Shadow DOM — Deep Dive

The Shadow DOM provides encapsulation for both markup and styles. When you attach a shadow root, the element gets its own isolated DOM tree. Styles defined inside the shadow tree do not affect the outside page, and external styles do not affect the shadow tree's interior.

There are two modes for attaching a shadow root:

ModeDescriptionAccessible via JS
openShadow root is accessible via element.shadowRootYes
closedelement.shadowRoot returns nullNo
shadow-dom.js
JavaScript
1class ShadowedComponent extends HTMLElement {
2 constructor() {
3 super();
4 // Attach shadow root in 'open' mode
5 const shadow = this.attachShadow({ mode: 'open' });
6
7 // Create internal DOM structure
8 const wrapper = document.createElement('div');
9 wrapper.className = 'wrapper';
10
11 const style = document.createElement('style');
12 style.textContent = `
13 .wrapper {
14 padding: 16px;
15 border: 1px solid #333;
16 border-radius: 8px;
17 background: #0D0D0D;
18 color: #E0E0E0;
19 font-family: system-ui;
20 }
21 .title {
22 color: #00FF41;
23 font-size: 14px;
24 font-weight: 600;
25 margin: 0 0 8px 0;
26 }
27 /* These styles are scoped — they won't leak out */
28 `;
29
30 const title = document.createElement('h2');
31 title.className = 'title';
32 title.textContent = this.getAttribute('title') || 'Shadow Component';
33 wrapper.appendChild(title);
34
35 // Create slot for user content
36 const slot = document.createElement('slot');
37 wrapper.appendChild(slot);
38
39 shadow.appendChild(style);
40 shadow.appendChild(wrapper);
41 }
42}
43
44customElements.define('shadowed-component', ShadowedComponent);

Using the shadowed component:

using-shadow-dom.html
HTML
1<shadowed-component title="My Card">
2 <!-- This content is projected into the slot -->
3 <p>This content is projected into the slot area.</p>
4 <button>Click me</button>
5</shadowed-component>
6
7<!-- CSS outside the shadow DOM will NOT affect internal styles -->
8<style>
9 /* These styles do NOT penetrate the shadow DOM */
10 .wrapper { background: red !important; } /* no effect */
11 shadowed-component { display: block; margin: 16px 0; } /* only host styles work */
12</style>

info

Use open mode for most cases — it allows external testing and debugging tools to inspect the shadow tree. Use closed mode only when you have a specific security or encapsulation reason to prevent external access. Closed mode can complicate testing and framework integration.
HTML Templates & Slots

The <template> element holds inert HTML that is not rendered until cloned and inserted by JavaScript. Combined with <slot> elements inside Shadow DOM, you get a declarative composition system similar to Vue slots or React children.

template-slot.html
HTML
1<!-- Define a template -->
2<template id="user-card-template">
3 <style>
4 .card {
5 border: 1px solid #333;
6 border-radius: 8px;
7 padding: 16px;
8 background: #111;
9 color: #E0E0E0;
10 font-family: system-ui;
11 }
12 .name { color: #00FF41; font-size: 16px; font-weight: 600; }
13 .role { color: #808080; font-size: 12px; }
14 ::slotted(img) {
15 width: 48px;
16 height: 48px;
17 border-radius: 50%;
18 border: 2px solid #00FF41;
19 }
20 </style>
21 <div class="card">
22 <slot name="avatar">
23 <div class="default-avatar">👤</div>
24 </slot>
25 <div class="name"><slot name="name">User Name</slot></div>
26 <div class="role"><slot name="role">Role</slot></div>
27 <slot></slot>
28 </div>
29</template>
30
31<!-- Usage with slot content -->
32<user-card>
33 <img slot="avatar" src="profile.jpg" alt="Avatar" />
34 <span slot="name">Jane Doe</span>
35 <span slot="role">Senior Engineer</span>
36 <p>Additional content goes to the default slot.</p>
37</user-card>

The corresponding JavaScript to define the custom element:

user-card.js
JavaScript
1class UserCard extends HTMLElement {
2 constructor() {
3 super();
4 const template = document.getElementById('user-card-template');
5 const shadow = this.attachShadow({ mode: 'open' });
6 shadow.appendChild(template.content.cloneNode(true));
7 }
8}
9
10customElements.define('user-card', UserCard);

best practice

Named slots (slot="name") let you target specific locations. The default slot (no name attribute) catches all content without a slot attribute. You can provide fallback content inside <slot> elements that displays when the consumer does not provide matching content.
Building a Complete Component

This example combines Custom Elements, Shadow DOM, Templates, and Slots into a fully functional rating component with attributes, events, and state management.

rating-component.html
HTML
1<!-- rating-component.html — A complete star rating widget -->
2
3<!-- Template for the component's shadow DOM -->
4<template id="rating-template">
5 <style>
6 :host {
7 display: inline-flex;
8 align-items: center;
9 gap: 4px;
10 font-family: system-ui;
11 }
12 :host([disabled]) {
13 opacity: 0.5;
14 pointer-events: none;
15 }
16 .star {
17 cursor: pointer;
18 font-size: var(--star-size, 24px);
19 transition: transform 0.15s, opacity 0.15s;
20 background: none;
21 border: none;
22 padding: 0;
23 line-height: 1;
24 }
25 .star:hover {
26 transform: scale(1.2);
27 }
28 .star.empty { opacity: 0.3; }
29 .star.filled { opacity: 1; color: #FFD700; }
30 .star.half {
31 position: relative;
32 display: inline-block;
33 }
34 .label {
35 font-size: 12px;
36 color: #808080;
37 margin-left: 8px;
38 }
39 </style>
40 <span id="stars"></span>
41 <span class="label" id="label"></span>
42</template>
43
44<div id="app">
45 <star-rating value="3" max="5"></star-rating>
46 <star-rating value="4" max="5" disabled></star-rating>
47</div>
48
49<script>
50 class StarRating extends HTMLElement {
51 static get observedAttributes() {
52 return ['value', 'max', 'disabled'];
53 }
54
55 constructor() {
56 super();
57 const template = document.getElementById('rating-template');
58 const shadow = this.attachShadow({ mode: 'open' });
59 shadow.appendChild(template.content.cloneNode(true));
60
61 this._starsContainer = shadow.getElementById('stars');
62 this._labelEl = shadow.getElementById('label');
63
64 this._value = parseInt(this.getAttribute('value')) || 0;
65 this._max = parseInt(this.getAttribute('max')) || 5;
66 }
67
68 connectedCallback() {
69 this.render();
70 this._starsContainer.addEventListener('click', (e) => {
71 const star = e.target.closest('.star');
72 if (!star || this.disabled) return;
73 const idx = parseInt(star.dataset.index);
74 this.setAttribute('value', String(idx + 1));
75 this.dispatchEvent(new CustomEvent('rating-change', {
76 detail: { value: idx + 1, max: this._max },
77 bubbles: true,
78 composed: true,
79 }));
80 });
81 }
82
83 attributeChangedCallback(name, oldVal, newVal) {
84 if (oldVal === newVal) return;
85 if (name === 'value') this._value = parseInt(newVal) || 0;
86 if (name === 'max') this._max = parseInt(newVal) || 5;
87 this.render();
88 }
89
90 render() {
91 if (!this._starsContainer) return;
92 this._starsContainer.innerHTML = '';
93 for (let i = 0; i < this._max; i++) {
94 const star = document.createElement('button');
95 star.className = `star ${i < this._value ? 'filled' : 'empty'}`;
96 star.dataset.index = i;
97 star.textContent = i < this._value ? '★' : '☆';
98 star.setAttribute('aria-label', `Star ${i + 1}`);
99 this._starsContainer.appendChild(star);
100 }
101 this._labelEl.textContent = `${this._value} / ${this._max}`;
102 }
103
104 get disabled() {
105 return this.hasAttribute('disabled');
106 }
107 }
108
109 customElements.define('star-rating', StarRating);
110</script>

Live preview of the star rating component:

preview
🔥

pro tip

The composed: true option in CustomEvent is critical when dispatching events from within Shadow DOM. Without it, the event will not bubble past the shadow boundary and parent frameworks will not receive it. Always set bubbles: true and composed: true for events that need to be handled outside the component.
Props & Attributes

Custom Elements accept data through HTML attributes and JavaScript properties. The observedAttributes static getter declares which attributes the component watches. Changes trigger attributeChangedCallback. For JavaScript properties, you define getters and setters directly on the element class.

toggle-switch.js
JavaScript
1class ToggleSwitch extends HTMLElement {
2 // Declare observed attributes for reactive updates
3 static get observedAttributes() {
4 return ['checked', 'disabled', 'label'];
5 }
6
7 constructor() {
8 super();
9 const shadow = this.attachShadow({ mode: 'open' });
10 shadow.innerHTML = `
11 <style>
12 :host { display: inline-flex; align-items: center; gap: 8px;
13 font-family: system-ui; cursor: pointer; }
14 .track { width: 40px; height: 22px; background: #333;
15 border-radius: 11px; position: relative;
16 transition: background 0.2s; }
17 .track.checked { background: #00FF41; }
18 .thumb { width: 18px; height: 18px; background: #E0E0E0;
19 border-radius: 50%; position: absolute; top: 2px;
20 left: 2px; transition: left 0.2s; }
21 .checked .thumb { left: 20px; }
22 :host([disabled]) { opacity: 0.5; pointer-events: none; }
23 .label-text { font-size: 12px; color: #808080; }
24 </style>
25 <div class="track" id="track">
26 <div class="thumb"></div>
27 </div>
28 <span class="label-text" id="labelText"></span>
29 `;
30 this._track = shadow.getElementById('track');
31 this._labelText = shadow.getElementById('labelText');
32 this._onClick = this._toggle.bind(this);
33 }
34
35 // Property getter/setter for checked
36 get checked() { return this.hasAttribute('checked'); }
37 set checked(val) {
38 if (val) this.setAttribute('checked', '');
39 else this.removeAttribute('checked');
40 }
41
42 get disabled() { return this.hasAttribute('disabled'); }
43 set disabled(val) {
44 if (val) this.setAttribute('disabled', '');
45 else this.removeAttribute('disabled');
46 }
47
48 connectedCallback() {
49 this.addEventListener('click', this._onClick);
50 this._update();
51 }
52
53 disconnectedCallback() {
54 this.removeEventListener('click', this._onClick);
55 }
56
57 attributeChangedCallback(name, oldVal, newVal) {
58 if (oldVal === newVal) return;
59 this._update();
60 if (name === 'checked') {
61 this.dispatchEvent(new CustomEvent('toggle', {
62 detail: { checked: this.checked },
63 bubbles: true,
64 composed: true,
65 }));
66 }
67 }
68
69 _toggle() {
70 if (this.disabled) return;
71 this.checked = !this.checked;
72 }
73
74 _update() {
75 if (!this._track) return;
76 this._track.className = `track ${this.checked ? 'checked' : ''}`;
77 this._labelText.textContent = this.getAttribute('label') || '';
78 }
79}
80
81customElements.define('toggle-switch', ToggleSwitch);
82
83// Usage:
84// <toggle-switch checked label="Dark Mode"></toggle-switch>
85// document.querySelector('toggle-switch').checked = true;

Live preview of the toggle switch:

preview
Events

Web Components communicate with the outside world through DOM events. The standard CustomEvent API lets you dispatch structured events with arbitrary data. When dispatching from within Shadow DOM, always set bubbles: true and composed: true so the event crosses the shadow boundary.

custom-events.js
JavaScript
1class EventDemo extends HTMLElement {
2 constructor() {
3 super();
4 const shadow = this.attachShadow({ mode: 'open' });
5 shadow.innerHTML = `
6 <style>
7 button { padding: 8px 16px; background: #00FF41; color: #000;
8 border: none; border-radius: 4px; font-size: 12px;
9 font-weight: 600; cursor: pointer;
10 font-family: system-ui; }
11 </style>
12 <button id="btn">Trigger Event</button>
13 `;
14 shadow.getElementById('btn')
15 .addEventListener('click', () => {
16 // Dispatch a custom event that crosses shadow boundary
17 this.dispatchEvent(new CustomEvent('action', {
18 detail: {
19 message: 'Button was clicked!',
20 timestamp: Date.now(),
21 source: 'event-demo',
22 },
23 bubbles: true, // bubble up through DOM tree
24 composed: true, // cross shadow boundary
25 }));
26 });
27 }
28}
29
30customElements.define('event-demo', EventDemo);
31
32// Listening for the event
33document.querySelector('event-demo')
34 .addEventListener('action', (e) => {
35 console.log(e.detail.message); // "Button was clicked!"
36 });

warning

Never use composed: false (the default) for events that need to be handled by parent frameworks. React's onClick and Vue's @click rely on event delegation at the document level, which fails if the event does not compose through the shadow boundary. Always use { bubbles: true, composed: true }.
Lifecycle Management

Proper lifecycle management prevents memory leaks and ensures your component behaves correctly across DOM mutations. The two critical callbacks are connectedCallback (set up listeners, fetch data) and disconnectedCallback (clean up timers, remove listeners).

lifecycle.js
JavaScript
1class LifecycleDemo extends HTMLElement {
2 constructor() {
3 super();
4 this._timer = null;
5 this._resizeHandler = this._onResize.bind(this);
6 this._intersectionObserver = null;
7 }
8
9 connectedCallback() {
10 // Set up interval
11 this._timer = setInterval(() => {
12 this.textContent = new Date().toLocaleTimeString();
13 }, 1000);
14
15 // Listen for resize
16 window.addEventListener('resize', this._resizeHandler);
17
18 // Observe intersection for lazy behavior
19 this._intersectionObserver = new IntersectionObserver(
20 (entries) => {
21 if (entries[0].isIntersecting) {
22 this.setAttribute('visible', '');
23 } else {
24 this.removeAttribute('visible');
25 }
26 }
27 );
28 this._intersectionObserver.observe(this);
29 }
30
31 disconnectedCallback() {
32 // Clean up EVERYTHING
33 clearInterval(this._timer);
34 window.removeEventListener('resize', this._resizeHandler);
35 this._intersectionObserver?.disconnect();
36 this._intersectionObserver = null;
37 }
38
39 _onResize() {
40 this.dispatchEvent(new CustomEvent('resized', {
41 detail: { width: window.innerWidth },
42 bubbles: true, composed: true,
43 }));
44 }
45}
46
47customElements.define('lifecycle-demo', LifecycleDemo);

best practice

Always clean up in disconnectedCallback. If your component sets up event listeners on window, document, or ResizeObserver/IntersectionObserver, failing to disconnect them will cause memory leaks. The constructor should only initialize state — do not access attributes or DOM here, as the element may not be fully parsed yet.
Framework Interop

One of Web Components' greatest strengths is framework interoperability. A component built with Web Components works in React, Vue, Angular, Svelte, or vanilla HTML without modification. However, each framework has specific considerations.

React

React passes all props as HTML attributes (not properties) by default. To pass complex data (objects, arrays), use ref to set properties directly on the DOM element. React's synthetic event system also requires composed: true on custom events.

react-wrapper.tsx
TSX
1function App() {
2 const toggleRef = useRef(null);
3
4 useEffect(() => {
5 // Set complex properties directly on the element
6 if (toggleRef.current) {
7 toggleRef.current.options = { theme: 'dark' };
8 }
9 }, []);
10
11 // React passes primitives as attributes
12 // Listen with addEventListener for custom events
13 useEffect(() => {
14 const el = toggleRef.current;
15 const handler = (e) => console.log(e.detail);
16 el?.addEventListener('toggle', handler);
17 return () => el?.removeEventListener('toggle', handler);
18 }, []);
19
20 return (
21 <div>
22 {/* Primitives work as attributes */}
23 <toggle-switch
24 ref={toggleRef}
25 checked
26 label="Enable feature"
27 />
28 </div>
29 );
30}

Vue

Vue has excellent Web Components support. Use v-bind for props and @ for events. Vue automatically detects custom elements if they contain a hyphen in the tag name. In Vue 3, set compilerOptions.isCustomElement in the config.

vue-wrapper.vue
HTML
1<!-- Vue 3 template — works out of the box -->
2<template>
3 <div>
4 <toggle-switch
5 :checked="isEnabled"
6 :label="labelText"
7 @toggle="onToggle"
8 />
9 </div>
10</template>
11
12<script setup>
13import { ref } from 'vue';
14
15const isEnabled = ref(true);
16const labelText = ref('Enable notifications');
17
18function onToggle(e) {
19 isEnabled.value = e.detail.checked;
20}
21</script>

Angular

Angular works well with Web Components. Register custom elements in CUSTOM_ELEMENTS_SCHEMA and use standard Angular bindings. For inputs, use [prop] binding; for events, use (event).

angular-wrapper.ts
TypeScript
1// app.module.ts
2import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
3
4@NgModule({
5 schemas: [CUSTOM_ELEMENTS_SCHEMA],
6 // ...
7})
8export class AppModule { }
9
10// app.component.html
11<toggle-switch
12 [attr.checked]="isChecked"
13 [attr.label]="'Dark Mode'"
14 (toggle)="onToggle($event)"
15></toggle-switch>

info

For React, consider using the @lit/react wrapper if you are using Lit-based components, or build a small wrapper component that maps React props to DOM properties and events. The key challenge is that React does not automatically pass JavaScript properties to custom elements — only HTML attributes.
Design System Components

Web Components are an excellent foundation for design systems because they work across any framework. Companies like Google (Material Web Components), Microsoft (Fluent UI), ING (Lion), and Adobe (Spectrum) use Web Components for their cross-platform design systems.

Key considerations for design system components:

Use CSS custom properties for theming — they penetrate Shadow DOM boundaries (unlike regular CSS). Define properties like --primary-color, --spacing-unit, --font-family.
Export CSS shadow parts using the ::part() selector for targeted styling by consumers. Add part="root" to your host element.
Provide a set of base tokens that consumers can override via CSS properties, making your components themeable without sacrificing encapsulation.
Version your components independently. Web Components deployed as npm packages with semantic versioning can be updated independently of the consuming application.
Use a consistent naming convention: company-name-element-name. This avoids collisions with other libraries.
design-system-button.js
JavaScript
1// Design system button with theming support
2class DSButton extends HTMLElement {
3 constructor() {
4 super();
5 const shadow = this.attachShadow({ mode: 'open' });
6 shadow.innerHTML = `
7 <style>
8 :host {
9 --btn-bg: var(--ds-primary, #00FF41);
10 --btn-color: var(--ds-on-primary, #000000);
11 --btn-radius: var(--ds-radius, 6px);
12 --btn-font: var(--ds-font, system-ui);
13 }
14 button {
15 background: var(--btn-bg);
16 color: var(--btn-color);
17 border: none;
18 border-radius: var(--btn-radius);
19 font-family: var(--btn-font);
20 padding: 10px 20px;
21 font-size: 13px;
22 font-weight: 600;
23 cursor: pointer;
24 transition: opacity 0.15s;
25 }
26 button:hover { opacity: 0.85; }
27 button:focus-visible {
28 outline: 2px solid var(--btn-bg);
29 outline-offset: 2px;
30 }
31 :host([variant="outline"]) button {
32 background: transparent;
33 border: 2px solid var(--btn-bg);
34 color: var(--btn-bg);
35 }
36 :host([size="small"]) button {
37 padding: 6px 12px;
38 font-size: 11px;
39 }
40 </style>
41 <button part="button"><slot></slot></button>
42 `;
43 }
44}
45
46customElements.define('ds-button', DSButton);
47
48/* Consumer theming:
49 ds-button { --ds-primary: #4FC3F7; --ds-radius: 12px; }
50 ds-button::part(button) { text-transform: uppercase; }
51*/

best practice

Design your Web Components with theming in mind from the start. Use CSS custom properties for every visual aspect — colors, spacing, typography, border radii. This gives downstream consumers flexibility without breaking encapsulation. The ::part() selector provides a controlled escape hatch for custom styling of specific internal elements.
Browser Support & Polyfills

All three Web Components technologies are supported natively in every modern browser. For legacy browser support (IE11 and older), lightweight polyfills are available.

FeatureChromeFirefoxSafariEdge
Custom Elements (v1)67+63+10.1+79+
Shadow DOM (v1)53+63+10.1+79+
<template>26+22+8+13+
<slot>53+63+10.1+79+

For legacy browser support, use the @webcomponents/webcomponentsjs polyfill. It detects missing features and loads only the needed polyfills:

polyfills.html
HTML
1<!-- Web Components polyfill for legacy browsers -->
2<script
3 src="https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-loader.js"
4 defer
5></script>
6
7<!-- Or conditionally load polyfills -->
8<script>
9 if (!('customElements' in window)) {
10 const script = document.createElement('script');
11 script.src = 'https://unpkg.com/@webcomponents/webcomponentsjs@2/custom-elements-es5-adapter.js';
12 document.head.appendChild(script);
13 }
14 if (!('attachShadow' in Element.prototype)) {
15 const script = document.createElement('script');
16 script.src = 'https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-sd-ce.js';
17 document.head.appendChild(script);
18 }
19</script>
📝

note

The webcomponentsjs v2 polyfill includes an ES5 adapter that is necessary if you transpile your code to ES5 (common for IE11 support). The adapter ensures that native ES2015 class-based Custom Elements work in ES5 contexts. Modern browsers do not need any polyfill.
Best Practices

Naming

Custom element names must contain a hyphen and should follow the pattern library-name-element-name. This prevents collisions with other libraries and future HTML elements. Examples: mwc-button, fluent-checkbox, lion-input.

Single Responsibility

Each component should do one thing well. A button component handles button behavior. A card component handles card layout. Compose small components together rather than building monolithic components. This improves testability, reusability, and maintainability.

Accessibility

Use native semantic elements inside your Shadow DOM whenever possible. A <button> inside Shadow DOM is still accessible to screen readers. Set aria-* attributes on the host element via this.setAttribute(). Use role attributes appropriately and manage focus for interactive components.

Performance

Use <template> for component markup rather than building DOM with innerHTML or createElement calls. Templates are parsed once and cloned efficiently. For lists, consider using connectedCallback for initial render and attributeChangedCallback for incremental updates. Avoid heavy work in constructor.

Testing

Web Components can be tested with standard DOM testing tools. Use document.createElement('my-element') to instantiate components in tests. Set attributes, dispatch events, and assert on the DOM. Tools like Web Test Runner, Playwright, and Cypress support Web Components natively.

Lit — The Recommended Base Library

While you can build Web Components with vanilla JavaScript, the Lit library from Google provides a reactive rendering system, declarative templates, and streamlined property management. It is the most popular Web Components library and is used in production by Adobe, IBM, Microsoft, and others. Lit components are still standard Web Components — they work in any framework.

🔥

pro tip

Start with vanilla Web Components for small, focused components. As your design system grows, adopt Lit to reduce boilerplate. Lit's @property() and @state() decorators handle attribute reflection, change detection, and rendering automatically — reducing the code needed for each component by roughly half.
$Blueprint — Engineering Documentation·Section ID: HTML-28·Revision: 1.0