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 Method | Trigger |
|---|---|
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 Element | Extends | Use Case |
|---|---|---|
<button is="..."> | HTMLButtonElement | Enhanced buttons with guardrails |
<input is="..."> | HTMLInputElement | Custom input types or validators |
<img is="..."> | HTMLImageElement | Lazy-loading or placeholder images |
<a is="..."> | HTMLAnchorElement | Enhanced navigation links |
Browser note: Safari does not support customized built-in elements (
isattribute). 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
| Aspect | Web Components | Framework Components (React, Vue, etc.) |
|---|---|---|
| Dependencies | None — native browser APIs | Requires framework library |
| Reusability | Works in any framework | Locked to one ecosystem |
| State management | Manual (attributes, properties) | Built-in reactive system |
| Data binding | Manual (attributeChangedCallback) | Automatic (virtual DOM, reactivity) |
| Styling | Shadow DOM encapsulation | CSS-in-JS, scoped styles via tooling |
| Ecosystem | Smaller | Large (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.
🔗 Related Topics
- HTML5 Semantic Tags — The semantic foundation for reusable components
- HTML Templates — Browser APIs for template-based development
- JavaScript in HTML — The scripting behind Web Components