Skip to main content

Web Components — Custom Elements & Shadow DOM

Mentor's Note: Web Components let you build your own HTML elements — like <my-card>, <user-profile>, or <star-rating> — that work anywhere. Think of them as LEGO bricks for the web: once you build a component, you can drop it into any project, and it just works. No framework required.

What Are Web Components?

Web Components are a set of browser-native APIs that let you create reusable, encapsulated custom HTML elements. Unlike framework components (React, Vue, Angular), Web Components are built directly into the browser — they work in any framework or no framework at all.

Three technologies work together to make Web Components possible:


HTML Templates (<template>)

The <template> element holds HTML that is not rendered until you clone it with JavaScript. Think of it as reusable HTML you can stamp out as many times as needed.

<template id="my-card">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h3 { margin: 0 0 0.5rem; color: #333; }
.card p { margin: 0; color: #666; }
</style>
<div class="card">
<h3><slot name="title">Default Title</slot></h3>
<p><slot name="content">Default content goes here.</slot></p>
</div>
</template>

Content inside <template> is inert — scripts don't run, images don't load, styles don't apply. It's just stored markup waiting to be used.

const template = document.getElementById('my-card');
const clone = template.content.cloneNode(true);
document.body.appendChild(clone);

The <slot> Element

Slots are placeholders inside a template that get filled with content from the element's children. They let you compose components like LEGO pieces.

Named Slots

A <slot> with a name attribute acts as a named insertion point. Content with the matching slot attribute fills it:

<!-- Template definition -->
<template id="user-card">
<div class="user-card">
<img slot="avatar" src="" alt="">
<h2><slot name="name">Name</slot></h2>
<p><slot name="bio">Bio here</slot></p>
</div>
</template>

<!-- Usage — content flows into named slots -->
<my-user-card>
<img slot="avatar" src="photo.jpg" alt="User photo">
<span slot="name">Jane Doe</span>
<span slot="bio">Frontend developer and web enthusiast.</span>
</my-user-card>

Default Slot (Unnamed)

A <slot> without a name catches all unmatched child content:

<template id="alert-box">
<div class="alert">
<slot></slot> <!-- catches everything not assigned to a named slot -->
</div>
</template>

<my-alert-box>
<strong>Warning!</strong> This is important.
</my-alert-box>
<!-- The <strong> and text appear in the default slot -->

Custom Elements with customElements.define()

Custom Elements let you define new HTML tags. The JavaScript API is minimal — the focus here is on how it connects to HTML.

Basic Custom Element

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Element Example</title>
</head>
<body>
<my-greeting name="World"></my-greeting>

<script>
class MyGreeting extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
const name = this.getAttribute('name') || 'Friend';
this.shadowRoot.innerHTML = `
<style>
p { font-size: 1.2rem; color: #2d3436; }
</style>
<p>Hello, ${name}! 👋</p>
`;
}
}

customElements.define('my-greeting', MyGreeting);
</script>
</body>
</html>
Lifecycle MethodTrigger
constructor()Element is created (before attributes are set)
connectedCallback()Element is inserted into the DOM
disconnectedCallback()Element is removed from the DOM
attributeChangedCallback(name, oldVal, newVal)An observed attribute changes

Observed Attributes

Tell the browser which attributes to watch so attributeChangedCallback fires:

<my-counter count="5"></my-counter>

<script>
class MyCounter extends HTMLElement {
static get observedAttributes() { return ['count']; }

constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.render();
}

attributeChangedCallback(name, oldVal, newVal) {
this.render();
}

render() {
const count = this.getAttribute('count') || '0';
this.shadowRoot.innerHTML = `<p>Count: ${count}</p>`;
}
}

customElements.define('my-counter', MyCounter);
</script>

Shadow DOM — Encapsulation

The Shadow DOM provides DOM encapsulation — your component's internals are hidden from the outside world. Styles don't leak in or out.

Shadow DOM Modes

// Open — accessible from outside via element.shadowRoot
this.attachShadow({ mode: 'open' });

// Closed — cannot be accessed externally
this.attachShadow({ mode: 'closed' });

Open mode is the standard choice. Closed mode breaks many developer tools and is rarely needed.


The is Attribute — Customized Built-in Elements

The is attribute lets you extend existing HTML elements instead of creating a brand-new tag. This preserves built-in behaviours like form submission and accessibility.

<!-- Extend a built-in <button> -->
<button is="confirm-button">Delete</button>

<script>
class ConfirmButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', (e) => {
if (!confirm('Are you sure?')) {
e.preventDefault();
}
});
}
}

customElements.define('confirm-button', ConfirmButton, { extends: 'button' });
</script>

Notice the third argument to customElements.define(){ extends: 'button' } tells the browser which element is being extended.

Built-in ElementExtendsUse Case
<button is="...">HTMLButtonElementEnhanced buttons with guardrails
<input is="...">HTMLInputElementCustom input types or validators
<img is="...">HTMLImageElementLazy-loading or placeholder images
<a is="...">HTMLAnchorElementEnhanced navigation links

Browser note: Safari does not support customized built-in elements (is attribute). Check compatibility before using this feature in production.


Browser Support

All major browsers support Custom Elements, Shadow DOM, and HTML Templates. The is attribute (customized built-ins) is the only feature with limited support.

For polyfills, use the @webcomponents/webcomponentsjs package to support older browsers.


Try It Yourself — Build a Star Rating Component

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Star Rating Web Component</title>
</head>
<body>
<star-rating max="5" value="3"></star-rating>
<star-rating max="5" value="4"></star-rating>

<script>
class StarRating extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
const max = parseInt(this.getAttribute('max')) || 5;
const value = parseInt(this.getAttribute('value')) || 0;

this.shadowRoot.innerHTML = `
<style>
.stars { display: inline-flex; gap: 4px; }
.star { font-size: 2rem; cursor: pointer; color: #ddd; transition: color 0.2s; }
.star.active { color: #f1c40f; }
.star:hover { color: #f39c12; }
</style>
<div class="stars">
${Array.from({ length: max }, (_, i) =>
`<span class="star ${i < value ? 'active' : ''}" data-index="${i}"></span>`
).join('')}
</div>
`;

this.shadowRoot.querySelectorAll('.star').forEach(star => {
star.addEventListener('click', () => {
this.setAttribute('value', parseInt(star.dataset.index) + 1);
});
});
}

static get observedAttributes() { return ['value']; }

attributeChangedCallback(name, oldVal, newVal) {
if (name === 'value' && this.shadowRoot) {
const value = parseInt(newVal) || 0;
this.shadowRoot.querySelectorAll('.star').forEach((star, i) => {
star.classList.toggle('active', i < value);
});
}
}
}

customElements.define('star-rating', StarRating);
</script>
</body>
</html>

Copy this into an .html file and open it in a browser. You should see interactive star ratings that update when clicked.


Web Components vs Frameworks

AspectWeb ComponentsFramework Components (React, Vue, etc.)
DependenciesNone — native browser APIsRequires framework library
ReusabilityWorks in any frameworkLocked to one ecosystem
State managementManual (attributes, properties)Built-in reactive system
Data bindingManual (attributeChangedCallback)Automatic (virtual DOM, reactivity)
StylingShadow DOM encapsulationCSS-in-JS, scoped styles via tooling
EcosystemSmallerLarge (routers, stores, UI kits)

Web Components are ideal for design systems, embeddable widgets, and framework-agnostic libraries. For complex application logic, a framework often provides better developer experience.