What you'll learn
Quick Answer
Web Components are browser APIs for building reusable custom HTML elements with no framework. You register a class with customElements.define(), attach a Shadow DOM to isolate its markup and CSS from the rest of the page, and use slot elements to let the page pass content in. The result works anywhere plain HTML does.
The three building blocks
Web Components is an umbrella term for three browser APIs that work together:
- Custom Elements let you define a new HTML tag backed by a JavaScript class, with hooks that run when the element is added to or removed from the page.
- Shadow DOM gives the element a private DOM subtree whose styles do not leak out and whose structure is not touched by page CSS or scripts.
- Slots and the
<template>element let the page pass markup into the component, which the component places wherever it wants.
None of this needs a build step, a bundler, or a framework. A custom element is just a class and one customElements.define() call. Because the browser owns the element, it survives framework changes and drops into React, Vue, a plain HTML file, or a CMS template without adaptation. The trade-off is that you write more boilerplate by hand and lose the ergonomics a framework would give you.
Defining a custom element
Extend HTMLElement, then register the class against a tag name:
class UserCard extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') || 'there';
this.textContent = `Hello, ${name}`;
}
}
customElements.define('user-card', UserCard);
// then in your HTML:
// <user-card name='Asha'></user-card>The tag name must contain a hyphen. user-card is valid; usercard throws a SyntaxError. The hyphen reserves a namespace for authors so the browser can add new single-word elements in future without ever clashing with yours.
This is an autonomous custom element. You can also extend a built-in, such as class FancyButton extends HTMLButtonElement registered with { extends: 'button' } and used as <button is='fancy-button'>, but Safari has declined to ship that form, so most teams stick to autonomous elements.
Lifecycle callbacks (and a gotcha)
The browser calls set methods on your class at set points:
constructor()runs when the element is created. Callsuper()first. You may not read attributes or add children here yet.connectedCallback()runs each time the element is inserted into the document. Setup belongs here.disconnectedCallback()runs on removal. Clean up timers and listeners.attributeChangedCallback(name, oldValue, newValue)runs when an observed attribute changes.
Only attributes listed in the static observedAttributes getter trigger that last callback. Here is a verified run under jsdom that creates the element, sets name, inserts it, changes name, then removes it:
static get observedAttributes() { return ['name']; }
// console output:
attr name: null -> Asha
connected Asha
attr name: Asha -> Ravi
disconnectedThe gotcha: attributeChangedCallback fired before connectedCallback. If you build your DOM in connectedCallback and read it in attributeChangedCallback, the first attribute call hits undefined. Build shared structure in the constructor, or guard every attribute handler with a check that setup has run.
Shadow DOM and style isolation
Call attachShadow in the constructor to give the element a private subtree:
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { display: block; border: 1px solid #ccc; }
.name { font-weight: 700; }
</style>
<span class='name'></span>
<slot></slot>`;
}Styles inside that <style> block cannot escape, and a page rule like span { color: red } cannot reach in. :host targets the element itself from inside its shadow tree. Two things still cross the boundary: inherited properties such as color and font-family, and CSS custom properties, which is the intended way to theme a component from outside.
mode: 'open' exposes the tree as element.shadowRoot. mode: 'closed' makes that return null. Closed mode is not a security boundary; it only signals intent, and code that really wants in can still get there. Note too that document.querySelector does not pierce shadow trees, which trips up test code and analytics scripts.
Passing content in with slots
A <slot> is a placeholder the page fills. Give slots names to accept content in specific places, and leave one unnamed as the default:
// inside the shadow root
<slot name='title'>Untitled</slot>
<slot></slot>
// on the page
<user-card>
<h3 slot='title'>Ravi Kumar</h3>
<p>Backend engineer</p>
</user-card>The <h3> lands in the title slot; the <p> falls through to the default slot. Text between the slot tags (Untitled) is fallback content, shown only when nothing is assigned.
Two behaviours to know. Slotted nodes stay in the light DOM: they inherit styles from their page ancestors, not from the shadow tree, and document.querySelector still finds them. And ::slotted(p) can style assigned elements from inside the component, but only the top-level ones. ::slotted(p span) matches nothing, because the selector cannot look deeper than the directly assigned node.
When to reach for Web Components
They fit best when a component must outlive one app or run inside someone else's page: design-system primitives shared across teams and frameworks, embeddable widgets, CMS or email-builder blocks, and anything a third party pastes into a site you do not control.
They fit worst as a wholesale React replacement. You give up JSX, reactive state, and a mature ecosystem, and you take on rough edges: server-side rendering needs Declarative Shadow DOM (<template shadowrootmode='open'>), which is recent; forms need ElementInternals to participate; and a flash of unstyled content is easy to hit before the defining script loads.
A pragmatic pattern is to author your design system as Web Components and consume them from whatever framework each product uses. The framework handles app logic and data flow; the components handle presentation and encapsulation, and stay stable while the frameworks around them change.
