Concept
Web Components are three separate browser-native specifications that together let you build reusable, encapsulated custom elements without any framework:
- Custom Elements, define new HTML tags (
<user-card>) backed by a JS class. - Shadow DOM, attach an encapsulated, style-isolated DOM subtree to an element.
- HTML Templates (
<template>), declare inert, reusable markup fragments that aren't rendered until explicitly cloned into the document.
They're standards, not a framework, the resulting <user-card> element works identically whether it's used in a plain HTML page, a React app, a Vue app, or anywhere else, because it's just a real HTML element as far as the browser and every framework's rendering is concerned. This is Web Components' core value proposition: framework-agnostic, portable UI primitives, particularly valuable for design systems shared across multiple frameworks/teams, or for embeddable third-party widgets.
Custom Elements
class UserCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div class="card">
<strong>${this.getAttribute("name")}</strong>
<p>${this.getAttribute("role")}</p>
</div>
`;
}
}
customElements.define("user-card", UserCard);<user-card name="Jane Doe" role="Engineer"></user-card>Custom element names must contain a hyphen (user-card, not usercard), this is a deliberate spec requirement guaranteeing no collision with any current or future native HTML element, since the platform reserves all-lowercase-no-hyphen names for itself.
Lifecycle callbacks
class UserCard extends HTMLElement {
static get observedAttributes() {
return ["name", "role"]; // only these trigger attributeChangedCallback
}
connectedCallback() {
// element inserted into the DOM, do setup, initial render here
}
disconnectedCallback() {
// element removed from the DOM, clean up listeners, timers, observers here
}
attributeChangedCallback(name, oldValue, newValue) {
// a watched attribute changed, re-render as needed
}
adoptedCallback() {
// element moved to a new document (rare, e.g. cross-iframe move)
}
}This mirrors what React's useEffect/cleanup or Vue's mounted/unmounted hooks give you, except it's the browser's own native lifecycle, available with zero framework runtime overhead.
Shadow DOM: real encapsulation
class UserCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
/* Scoped ONLY to this shadow root, cannot leak out, cannot be overridden from outside */
.card { border: 1px solid #ccc; padding: 1rem; border-radius: 8px; }
</style>
<div class="card"><slot></slot></div>
`;
}
}<user-card>
<p>This content is "slotted" into the shadow DOM's <slot></p>
</user-card>Shadow DOM gives you genuine style and DOM encapsulation, CSS written inside a shadow root cannot leak out and affect the rest of the page, and (mostly) the page's global CSS cannot reach in and affect shadow DOM internals either. This solves the "CSS class name collision at scale" problem CSS-in-JS, CSS Modules, and BEM naming conventions all exist to work around in JS-framework land, Shadow DOM solves it natively, with a real browser-enforced boundary rather than a build-time naming convention.