Web Components
Custom elements, Shadow DOM encapsulation, and a complete working custom element example.
What "web components" actually means
Web Components isn't one API — it's a name for three separate browser standards that combine to let you define a genuinely new, reusable HTML element, with its own encapsulated markup, styling, and behavior, usable in any project regardless of framework:
- Custom Elements — the ability to define a new tag name (
<user-card>) backed by a JavaScript class, with its own lifecycle callbacks. - Shadow DOM — an encapsulated, isolated DOM subtree attached to an element, whose internal markup and CSS can't leak out and can't be reached in by ordinary page-level selectors.
- HTML Templates — the
<template>element (already covered on the HTML5 Features page in this track) for declaring inert, reusable markup that's cloned into place on demand.
Together, these let you ship a component — say, a rating widget or a custom date picker — as a single <my-widget> tag that works identically whether it's dropped into a plain HTML page, a Laravel Blade view, a React app, or a Vue app, since it's built entirely on browser-native APIs rather than a specific framework's component model.
Defining a custom element
Every custom element is a class extending HTMLElement, registered with customElements.define(). Its tag name is required to contain a hyphen — this is a deliberate spec rule, guaranteeing a custom element's name can never collide with a future standard HTML element:
class GreetingBanner extends HTMLElement {
connectedCallback() {
// Runs once this element is actually inserted into the document
this.textContent = `Hello, ${this.getAttribute('name') || 'there'}!`;
}
}
customElements.define('greeting-banner', GreetingBanner);
<greeting-banner name="Ada"></greeting-banner>
The most important lifecycle callbacks:
| Callback | Runs when |
|---|---|
connectedCallback |
The element is inserted into the document — the standard place to do initial rendering. |
disconnectedCallback |
The element is removed from the document — the place to clean up event listeners, timers, or subscriptions. |
attributeChangedCallback |
An attribute listed in observedAttributes changes — lets the element react live to attribute updates. |
Shadow DOM, conceptually
Attaching a shadow root gives an element its own private DOM tree, rendered as part of the page but invisible to ordinary document.querySelector() calls from outside, and — critically — with its own isolated style scope:
class Example extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
p { color: red; } /* only affects <p> elements inside THIS shadow root */
</style>
<p>Styled only inside the shadow root.</p>
`;
}
}
A <p> anywhere else on the page is completely unaffected by that color: red rule, and the page's own global CSS can't reach in and restyle the <p> inside the shadow root either (short of a few deliberately-designed CSS custom-property and ::part() escape hatches). This is the core promise of Shadow DOM: a component's internal styles and internal markup structure are genuinely sealed off, in both directions — which is exactly the isolation problem CSS alone can't solve, no matter how disciplined a naming convention (like BEM) you apply to plain classes.
{ mode: 'open' } allows JavaScript outside the component to still reach in via element.shadowRoot (mainly useful for testing/debugging); { mode: 'closed' } blocks even that, fully sealing the internal DOM.
A complete minimal example: <user-card>
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['name', 'role'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || 'Unknown';
const role = this.getAttribute('role') || 'Member';
this.shadowRoot.innerHTML = `
<style>
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
font-family: sans-serif;
max-width: 220px;
}
.name { font-weight: bold; }
.role { color: #6b7280; font-size: 0.875rem; }
</style>
<div class="card">
<div class="name">${name}</div>
<div class="role">${role}</div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
<user-card name="Jane Doe" role="Engineer"></user-card>
<user-card name="Sam Rivera" role="Designer"></user-card>
Each <user-card> renders independently, with its own sealed .card/.name/.role styles that can't clash with a .card class already used elsewhere on the same page — including, crucially, another different .card class defined by whatever CSS framework the surrounding page happens to use. observedAttributes plus attributeChangedCallback means changing name or role on an existing <user-card> from JavaScript re-renders it automatically, the same way a framework component re-renders on a prop change.
When to reach for this vs. a framework
Web Components are a genuinely good fit for a self-contained, visually isolated widget meant to be dropped into many different unrelated contexts — a third-party embeddable widget, a design-system primitive shared across several separate frontends built with different frameworks, or a piece of a legacy multi-page app that isn't otherwise using any framework at all. They're a weaker fit for building an entire application's UI: there's no built-in state management, no templating syntax as expressive as JSX or Vue's templates, and Shadow DOM's style isolation can actively fight against a project standardized on a single global design system (a utility framework's classes, for instance, don't cross a shadow boundary). Most teams building a full app still reach for React, Vue, or a similar framework, and use Web Components selectively for the handful of components that genuinely need framework-agnostic portability.
Common mistakes
- Forgetting the required hyphen in a custom element's tag name (
<usercard>instead of<user-card>) — the spec requires it, andcustomElements.define()throws if the name doesn't contain one. - Doing DOM setup in the constructor instead of
connectedCallback— the constructor runs before the element is guaranteed to be in the document, and some operations (like reading attributes reliably) are safer deferred toconnectedCallback. - Expecting page-level CSS (a framework's utility classes, a global stylesheet) to style the inside of an open shadow root — it won't, by design; only
:host, CSS custom properties, and::part()cross that boundary. - Reaching for a hand-rolled custom element to solve a problem a framework you're already using solves natively (state, reactivity, templating) — Web Components shine for portable, standalone widgets, not as a full replacement for an app's component framework.