Skip to main content

Web Components in JavaScript

Introduction

Modern web development is dominated by frameworks like React, Vue, and Angular. Each offers its own way to create reusable UI components, but they all share the same fundamental problem: lock-in. A React component cannot be used in a Vue project. An Angular directive is meaningless in Svelte. When frameworks rise and fall, entire component libraries become obsolete.

Web Components solve this problem at the platform level. They are a set of native browser APIs that let you create custom, reusable, encapsulated HTML elements that work everywhere: in any framework, in no framework, today and ten years from now. A Web Component you write today will work in React, Vue, Angular, plain HTML, or whatever comes next, because it is built on web standards, not library abstractions.

Web Components are not new. The specifications have been developed and refined over many years, and as of today, they are fully supported in all modern browsers. Major companies like Google, GitHub, Adobe, Salesforce, SAP, and ING Bank use Web Components in production. YouTube's entire frontend is built with them. GitHub's interface is packed with custom elements like <details-dialog>, <tab-container>, and <clipboard-copy>.

In this guide, you will learn what Web Components are, understand the four pillars that make them work, discover why they matter, and check their browser support status. This is the overview that sets the foundation for the deep-dive articles that follow.

The Four Pillars: Custom Elements, Shadow DOM, Templates, Slots

Web Components are not a single API. They are a collection of four complementary browser standards that work together to let you define truly custom, self-contained HTML elements. Each pillar addresses a different concern.

Pillar 1: Custom Elements

Custom Elements is the API that lets you define your own HTML tags with custom behavior. Instead of relying on <div class="user-card"> and attaching behavior through JavaScript, you create a <user-card> element that is a first-class citizen of the DOM.

There are two types:

Autonomous Custom Elements extend the base HTMLElement class and create entirely new tags:

class UserCard extends HTMLElement {
constructor() {
super();
this.innerHTML = `<p>Hello, I am a custom element!</p>`;
}
}

// Register the element with a tag name (must contain a hyphen)
customElements.define('user-card', UserCard);

Now you can use it in HTML like any built-in element:

<user-card></user-card>

Output rendered in the browser:

Hello, I am a custom element!

Customized Built-in Elements extend existing HTML elements to add behavior:

class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.style.background = 'linear-gradient(45deg, #667eea, #764ba2)';
this.style.color = 'white';
this.style.border = 'none';
this.style.padding = '10px 20px';
this.style.borderRadius = '6px';
this.style.cursor = 'pointer';
}
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });

Used with the is attribute:

<button is="fancy-button">Click Me</button>

Custom Elements provide lifecycle callbacks that let you react to the element's life in the DOM:

class UserCard extends HTMLElement {
connectedCallback() {
// Called when the element is added to the DOM
console.log('Element added to page');
}

disconnectedCallback() {
// Called when the element is removed from the DOM
console.log('Element removed from page');
}

attributeChangedCallback(name, oldValue, newValue) {
// Called when an observed attribute changes
console.log(`Attribute "${name}" changed from "${oldValue}" to "${newValue}"`);
}

static get observedAttributes() {
// Which attributes to watch
return ['name', 'avatar'];
}
}
info

Custom element tag names must contain a hyphen (-). This is a hard requirement that prevents name collisions with current and future standard HTML elements. Names like user-card, app-header, my-widget are valid. Names like usercard, card, or header are not.

Pillar 2: Shadow DOM

Shadow DOM provides encapsulation for your component's internal structure and styles. It creates a hidden, separate DOM tree attached to your element that is isolated from the rest of the page.

Without Shadow DOM, your component's styles can leak out and affect the rest of the page, and external styles can leak in and break your component. Shadow DOM eliminates both problems.

class UserCard extends HTMLElement {
constructor() {
super();

// Create a shadow root (hidden DOM tree)
const shadow = this.attachShadow({ mode: 'open' });

// Everything inside the shadow root is encapsulated
shadow.innerHTML = `
<style>
/* These styles ONLY affect this component */
.card {
border: 2px solid #3498db;
border-radius: 8px;
padding: 16px;
font-family: Arial, sans-serif;
}
h2 {
color: #2c3e50;
margin: 0 0 8px 0;
}
p {
color: #7f8c8d;
margin: 0;
}
</style>
<div class="card">
<h2>User Name</h2>
<p>user@example.com</p>
</div>
`;
}
}

customElements.define('user-card', UserCard);
<!-- External styles have NO effect on the component's internals -->
<style>
h2 { color: red; font-size: 50px; }
p { text-decoration: line-through; }
</style>

<h2>This heading IS red and 50px (outside Shadow DOM)</h2>
<user-card></user-card>
<!-- The h2 inside <user-card> stays #2c3e50, unaffected -->

Key points about Shadow DOM:

  • Styles are scoped: CSS inside the shadow root does not leak out. CSS outside does not leak in (with some exceptions like inherited properties).
  • DOM is hidden: document.querySelector('h2') will not find the <h2> inside a shadow root. The component's internals are invisible to external JavaScript.
  • mode: 'open' means external JavaScript can access the shadow root via element.shadowRoot. mode: 'closed' hides it completely (even .shadowRoot returns null).

Pillar 3: HTML Templates

The <template> element lets you declare blocks of HTML that are parsed but not rendered. They exist in the document as inert content, ready to be cloned and inserted when needed.

<template id="user-card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #3498db;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 20px;
}
.info h3 { margin: 0 0 4px 0; }
.info p { margin: 0; color: #666; }
</style>
<div class="card">
<div class="avatar"></div>
<div class="info">
<h3></h3>
<p></p>
</div>
</div>
</template>

The template above is invisible on the page. Nothing is rendered. You use it from JavaScript by cloning its content:

class UserCard extends HTMLElement {
connectedCallback() {
const template = document.getElementById('user-card-template');
const content = template.content.cloneNode(true); // Deep clone

// Populate with data from attributes
const name = this.getAttribute('name') || 'Unknown';
const email = this.getAttribute('email') || '';

content.querySelector('.avatar').textContent = name.charAt(0).toUpperCase();
content.querySelector('h3').textContent = name;
content.querySelector('p').textContent = email;

const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(content);
}
}

customElements.define('user-card', UserCard);
<user-card name="Alice" email="alice@example.com"></user-card>
<user-card name="Bob" email="bob@example.com"></user-card>

Each <user-card> gets its own clone of the template, populated with its own data. Templates are especially useful for:

  • Performance: HTML is parsed once and cloned cheaply, instead of being parsed from a string each time.
  • Separation: The markup structure stays in HTML, not buried in JavaScript strings.
  • Reuse: Multiple components can clone the same template.
tip

You do not have to use <template> elements in your HTML file. Many Web Component authors create templates entirely in JavaScript using document.createElement('template') and setting .innerHTML. Both approaches work fine. The HTML <template> element is more useful when you want to keep markup separate from logic.

Pillar 4: Slots

Slots are the mechanism for composition. They let users of your component inject their own content into specific places inside the component's Shadow DOM.

Think of slots as placeholder holes in your component that consumers can fill with their own HTML.

class AlertBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.alert {
border: 2px solid #e74c3c;
border-radius: 8px;
padding: 16px;
background: #fdf0ef;
}
.title {
font-weight: bold;
color: #e74c3c;
margin-bottom: 8px;
}
.content {
color: #333;
}
</style>
<div class="alert">
<div class="title">
<slot name="title">Default Title</slot>
</div>
<div class="content">
<slot>Default content goes here</slot>
</div>
</div>
`;
}
}

customElements.define('alert-box', AlertBox);

Usage with slotted content:

<alert-box>
<span slot="title">Warning!</span>
<p>Your session will expire in 5 minutes. Please save your work.</p>
</alert-box>

<alert-box>
<span slot="title">Error</span>
<p>Failed to connect to the server.</p>
<button>Retry</button>
</alert-box>

<!-- Using default content (no children provided) -->
<alert-box></alert-box>

The first <alert-box> renders "Warning!" in the title area and the paragraph in the content area. The second renders "Error" with a paragraph and a button. The third uses the fallback text ("Default Title" and "Default content goes here") because no children were provided.

Key slot concepts:

  • Named slots (<slot name="title">) receive elements with a matching slot="title" attribute.
  • Default slot (<slot> without a name) receives all child elements that do not have a slot attribute.
  • Fallback content: Text inside <slot>...</slot> is shown only when no content is provided by the consumer.

How the Four Pillars Work Together

Here is a complete example showing all four pillars in action:

<!-- PILLAR 3: Template -->
<template id="product-card-template">
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 12px;
overflow: hidden;
font-family: system-ui, sans-serif;
max-width: 300px;
}
:host(:hover) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.image-area {
background: #f5f5f5;
padding: 20px;
text-align: center;
}
.details {
padding: 16px;
}
.name {
font-size: 18px;
font-weight: bold;
margin: 0 0 8px 0;
}
.price {
font-size: 22px;
color: #27ae60;
font-weight: bold;
margin: 0 0 12px 0;
}
.actions {
display: flex;
gap: 8px;
}
</style>
<div class="image-area">
<!-- PILLAR 4: Named Slot for image -->
<slot name="image">📦</slot>
</div>
<div class="details">
<p class="name"></p>
<p class="price"></p>
<div class="actions">
<!-- PILLAR 4: Default Slot for action buttons -->
<slot>No actions available</slot>
</div>
</div>
</template>

<script>
// PILLAR 1: Custom Element
class ProductCard extends HTMLElement {
static get observedAttributes() {
return ['name', 'price'];
}

connectedCallback() {
// PILLAR 2: Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });

// PILLAR 3: Clone template
const template = document.getElementById('product-card-template');
shadow.appendChild(template.content.cloneNode(true));

this.render();
}

attributeChangedCallback() {
this.render();
}

render() {
if (!this.shadowRoot) return;

const name = this.getAttribute('name') || 'Unnamed Product';
const price = this.getAttribute('price') || '0.00';

this.shadowRoot.querySelector('.name').textContent = name;
this.shadowRoot.querySelector('.price').textContent = `$${price}`;
}
}

customElements.define('product-card', ProductCard);
</script>

<!-- Usage -->
<product-card name="Wireless Headphones" price="79.99">
<img slot="image" src="headphones.jpg" alt="Headphones" width="150">
<button>Add to Cart</button>
<button>Wishlist</button>
</product-card>

<product-card name="USB Cable" price="9.99">
<span slot="image" style="font-size: 80px;">🔌</span>
<button>Add to Cart</button>
</product-card>

In this example:

  • Custom Elements define <product-card> as a new HTML tag with lifecycle hooks.
  • Shadow DOM encapsulates the card's styles and structure, preventing style leaks.
  • Templates provide the HTML structure efficiently, parsed once and cloned per instance.
  • Slots let consumers inject their own images and action buttons into the component.

Why Web Components? (Framework-Agnostic, Encapsulated)

With powerful frameworks like React, Vue, and Angular available, you might wonder: why bother with Web Components? The answer lies in what Web Components offer that no framework can.

Framework Agnostic

Web Components are native browser features. They do not belong to any framework. A Web Component works in:

  • Plain HTML/JavaScript (no build step needed)
  • React
  • Vue
  • Angular
  • Svelte
  • Next.js, Nuxt, Astro, and any other meta-framework
  • Any future framework that does not exist yet

This makes Web Components ideal for:

Design Systems and Component Libraries

If your organization uses multiple frameworks across teams (marketing uses Vue, the main app uses React, the mobile web uses Svelte), Web Components let you build a single component library that works everywhere:

<!-- Works the same in React, Vue, Angular, or plain HTML -->
<company-button variant="primary" size="large">
Submit Order
</company-button>

<company-date-picker
min="2024-01-01"
max="2024-12-31"
onchange="handleDate(event)">
</company-date-picker>

Companies like Salesforce (Lightning Web Components), Adobe (Spectrum Web Components), SAP (UI5 Web Components), and ING Bank (Lion Web Components) use this approach in production.

Third-Party Widgets

If you are building an embeddable widget (payment form, chat widget, analytics dashboard), Web Components ensure your widget works regardless of what the host page uses:

<!-- The host page could be built with anything -->
<my-chat-widget api-key="abc123" theme="dark"></my-chat-widget>

Micro-Frontends

In micro-frontend architectures, different teams own different parts of the page. Web Components provide clean boundaries between these independently developed sections.

True Encapsulation

Framework components offer convention-based isolation (CSS Modules, scoped styles, BEM naming). Web Components offer enforced, browser-level isolation through Shadow DOM.

Consider this scenario with a framework approach:

<!-- Framework component (scoped CSS) -->
<style scoped>
.button { background: blue; }
</style>
<button class="button">Click me</button>

<!-- But a global style or third-party CSS can still override it -->
<style>
.button { background: red !important; }
</style>

The framework's scoping can be broken by !important, by specificity conflicts, or by CSS-in-JS collisions. With Shadow DOM:

class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button { background: blue; color: white; padding: 8px 16px; border: none; }
</style>
<button><slot>Click me</slot></button>
`;
}
}
customElements.define('my-button', MyButton);
<!-- This has ZERO effect on the button inside Shadow DOM -->
<style>
button { background: red !important; font-size: 100px !important; }
</style>

<button>I am red and huge</button>
<my-button>I am blue and normal</my-button>

The <my-button> element is completely immune to external CSS. No amount of specificity or !important can pierce the shadow boundary (except for inherited properties like font-family and color, and CSS custom properties, which are intentionally designed to pass through).

No Build Step Required

Web Components work with zero tooling. No Webpack, no Vite, no Babel, no npm. You can write a Web Component in a single HTML file and open it directly in a browser:

<!DOCTYPE html>
<html>
<body>
<greeting-message name="World"></greeting-message>

<script>
class GreetingMessage extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || 'Stranger';
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
p {
font-size: 24px;
color: #2c3e50;
font-family: Georgia, serif;
}
</style>
<p>Hello, ${name}!</p>
`;
}
}
customElements.define('greeting-message', GreetingMessage);
</script>
</body>
</html>

Open this file in any modern browser and it works. No node_modules, no package.json, no build pipeline. This makes Web Components excellent for quick prototypes, documentation sites, static pages, and environments where a build step is impractical.

Standards-Based Longevity

Frameworks come and go. jQuery dominated, then Backbone, then Angular 1, then React. Each transition meant rewriting components from scratch. Web Components, being W3C standards implemented natively by browsers, are as permanent as <div>, <input>, or <video>. They are not going away.

A Web Component written in 2019 still works perfectly in 2024, and it will work in 2030. No dependency updates, no breaking changes from library authors, no migration guides.

When Web Components Are Not the Best Choice

Web Components are not a replacement for frameworks in every scenario. Be aware of their trade-offs:

StrengthLimitation
Framework-agnosticNo built-in state management
True style encapsulationNo built-in reactivity or templating system
No build step neededMore verbose than framework component syntax
Browser-native, zero dependenciesServer-side rendering (SSR) support is limited
Long-term stabilityRich ecosystem of ready-made components is smaller
Works in any contextComplex apps need additional libraries or patterns
tip

Web Components and frameworks are not mutually exclusive. Many successful architectures use both. For example, you might use Web Components for your shared design system (buttons, inputs, cards, modals) and a framework like React or Vue for application-level logic (routing, state management, data fetching). Libraries like Lit (by Google) add lightweight reactivity and templating on top of Web Components, giving you a framework-like developer experience with the interoperability of native elements.

Real-World Web Component Libraries and Frameworks

If you want to see Web Components in action at scale, explore these production-ready projects:

Library/FrameworkCreatorDescription
LitGoogleLightweight library for building Web Components with reactive properties and declarative templates
Shoelace / Web AwesomeCommunityBeautiful, accessible component library built entirely with Web Components
FASTMicrosoftHigh-performance Web Component library and design system toolkit
Spectrum Web ComponentsAdobeAdobe's design system implemented as Web Components
Lightning Web ComponentsSalesforceEnterprise-grade Web Components for the Salesforce platform
StencilIonicCompiler that generates standards-compliant Web Components with a JSX-like syntax
UI5 Web ComponentsSAPEnterprise UI components for SAP applications

Browser Support and Polyfills

One of the most common questions about Web Components is: "Can I actually use them?" The answer in 2024 is a clear yes.

Current Browser Support

All four pillars of Web Components are fully supported in all modern browsers:

FeatureChromeFirefoxSafariEdge
Custom Elements (v1)54+63+10.1+79+
Shadow DOM (v1)53+63+10+79+
HTML Templates26+22+8+13+
Slots53+63+10+79+

This covers over 97% of global browser usage according to Can I Use data.

What About Internet Explorer?

Internet Explorer does not support Web Components, but IE was officially retired by Microsoft in June 2022. If you still need IE support (some legacy enterprise environments), polyfills were available, but at this point, dropping IE is the recommended approach.

Polyfills (For Older Browsers)

If you need to support older browser versions, the @webcomponents/webcomponentsjs polyfill package provides full support:

npm install @webcomponents/webcomponentsjs

Load the polyfill before your Web Component code:

<!-- Automatically loads only the polyfills needed by the current browser -->
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>

<!-- Your components -->
<script src="my-components.js"></script>

The webcomponents-loader.js script detects which features the browser is missing and loads only the necessary polyfills. In modern browsers, it loads nothing.

You can also use a CDN:

<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-loader.js"></script>

Feature Detection

You can check for Web Component support programmatically:

// Check for Custom Elements support
if ('customElements' in window) {
console.log('Custom Elements supported');
} else {
console.log('Custom Elements NOT supported, load polyfill');
}

// Check for Shadow DOM support
if (document.head.attachShadow) {
console.log('Shadow DOM supported');
} else {
console.log('Shadow DOM NOT supported, load polyfill');
}

// Check for HTML Templates support
if ('content' in document.createElement('template')) {
console.log('HTML Templates supported');
} else {
console.log('HTML Templates NOT supported');
}

// Check if a specific custom element is defined
if (customElements.get('my-element')) {
console.log('<my-element> is already defined');
} else {
console.log('<my-element> is not yet defined');
}

// Wait for a custom element to be defined
customElements.whenDefined('my-element').then(() => {
console.log('<my-element> is now available');
});

The customElements.whenDefined() Pattern

Since custom elements can be loaded asynchronously (deferred scripts, dynamic imports), there may be a moment when the browser encounters a custom element tag before its class is registered. The browser treats it as an unknown element (similar to a <span>) and then upgrades it when the definition becomes available.

You can wait for this upgrade:

// Wait until <user-card> is defined, then use it
customElements.whenDefined('user-card').then(() => {
document.querySelectorAll('user-card').forEach(card => {
console.log('Card is ready:', card.getAttribute('name'));
});
});

You can also use CSS to style undefined custom elements differently:

/* Style elements that are not yet defined (still upgrading) */
user-card:not(:defined) {
opacity: 0;
transition: opacity 0.3s;
}

/* Style elements after they are defined and upgraded */
user-card:defined {
opacity: 1;
}

This prevents a flash of unstyled content (FOUC) while the component's JavaScript loads.

note

The :defined CSS pseudo-class is supported in all browsers that support Custom Elements. It is the recommended way to handle the brief moment between an element appearing in HTML and its JavaScript definition being registered.

Polyfill Limitations

While polyfills make Web Components work in older browsers, there are some caveats:

  • Performance: Polyfilled Shadow DOM (called "shady DOM") is slower than native Shadow DOM because it cannot truly encapsulate styles at the browser level. It rewrites CSS selectors to simulate scoping.
  • CSS scoping: Polyfilled scoping is not perfect. Some edge cases in CSS specificity and inheritance behave differently.
  • Mutation observers: The polyfills use MutationObserver internally, which adds overhead.
  • No polyfill for Constructable Stylesheets or adoptedStyleSheets in very old browsers.

For new projects in 2024, you almost certainly do not need polyfills. The native browser support is excellent.

A Minimal Complete Example

To tie everything together, here is the simplest possible example demonstrating a useful, real-world Web Component with all four pillars:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Web Components Demo</title>
<style>
body { font-family: system-ui; padding: 20px; }
/* Prevent flash of unstyled content */
collapsible-panel:not(:defined) { display: none; }
</style>
</head>
<body>

<!-- PILLAR 3: Template -->
<template id="collapsible-template">
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8f9fa;
cursor: pointer;
user-select: none;
}
.header:hover { background: #e9ecef; }
.arrow {
transition: transform 0.2s;
font-size: 12px;
}
:host([open]) .arrow {
transform: rotate(90deg);
}
.body {
display: none;
padding: 16px;
border-top: 1px solid #ddd;
}
:host([open]) .body {
display: block;
}
</style>
<div class="header">
<!-- PILLAR 4: Named slot for the title -->
<slot name="title">Untitled Section</slot>
<span class="arrow"></span>
</div>
<div class="body">
<!-- PILLAR 4: Default slot for the content -->
<slot></slot>
</div>
</template>

<script>
// PILLAR 1: Custom Element
class CollapsiblePanel extends HTMLElement {
static get observedAttributes() {
return ['open'];
}

connectedCallback() {
// PILLAR 2: Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });

// PILLAR 3: Clone template
const template = document.getElementById('collapsible-template');
shadow.appendChild(template.content.cloneNode(true));

// Toggle open/closed on header click
shadow.querySelector('.header').addEventListener('click', () => {
this.toggleAttribute('open');
});
}
}

customElements.define('collapsible-panel', CollapsiblePanel);
</script>

<!-- Usage: consumers provide their own content via PILLAR 4: Slots -->
<collapsible-panel open>
<strong slot="title">What are Web Components?</strong>
<p>Web Components are a set of native browser APIs for creating
reusable, encapsulated custom HTML elements.</p>
</collapsible-panel>

<collapsible-panel>
<strong slot="title">Do I need a framework?</strong>
<p>No! Web Components work with zero dependencies.
But they also integrate with any framework.</p>
</collapsible-panel>

<collapsible-panel>
<strong slot="title">Browser support?</strong>
<p>All modern browsers fully support Web Components.
Over 97% global coverage.</p>
</collapsible-panel>

</body>
</html>

This single HTML file, with no build step and no dependencies, creates a fully functional, styled, encapsulated, reusable collapsible panel component. Open it in any browser and it works.

Summary

Web Components are a set of four browser-native APIs that let you create custom, reusable, encapsulated HTML elements:

  • Custom Elements let you define new HTML tags (<my-component>) with lifecycle callbacks and custom behavior.
  • Shadow DOM provides true encapsulation, isolating a component's internal DOM and styles from the rest of the page.
  • HTML Templates (<template>) allow you to declare inert HTML that is parsed once and cloned efficiently for each component instance.
  • Slots (<slot>) enable composition, letting component consumers inject their own content into designated areas of the component.

Web Components are framework-agnostic (they work everywhere), truly encapsulated (Shadow DOM enforced isolation, not convention-based), require no build step, and are based on W3C standards with long-term browser support guarantees. They are ideal for shared design systems, embeddable widgets, and any situation where interoperability matters.

All modern browsers fully support Web Components, covering over 97% of global usage. Polyfills exist for edge cases but are rarely needed for new projects in 2024.

The following articles in this series will deep-dive into each pillar: Custom Elements and their lifecycle, Shadow DOM and its styling rules, the Template element, Slots and composition patterns, Shadow DOM styling techniques, and how events work across shadow boundaries.