What “Web Components” actually is
“Web Components” isn’t one feature — it’s an umbrella term for three separate, independent browser APIs that work well together:
- Custom Elements — define your own HTML tags with their own behavior
- Shadow DOM — attach an encapsulated DOM subtree to an element, isolated from the rest of the page’s CSS/JS
<template>/<slot>— declare reusable HTML fragments and let consumers project content into them
You can use any of the three on their own. A component doesn’t need shadow DOM, and shadow DOM doesn’t require you to build a custom element.
1. Custom Elements
A custom element is a class extending HTMLElement, registered with a tag name that must contain a hyphen (this is a platform requirement — it’s how the browser distinguishes your tags from future built-in HTML elements).
class MyCard extends HTMLElement {
constructor() {
super();
// set up internal state here — but don't touch attributes/children yet,
// the element isn't guaranteed to be fully parsed at construction time
}
}
customElements.define('my-card', MyCard);Now <my-card></my-card> works anywhere in your HTML like a native element.
Lifecycle callbacks
These are the methods the browser calls automatically at specific points:
class MyCard extends HTMLElement {
connectedCallback() {
// called when the element is inserted into the DOM
// this is where you should do your actual setup/rendering
}
disconnectedCallback() {
// called when removed from the DOM — clean up listeners, timers, etc.
}
attributeChangedCallback(name, oldValue, newValue) {
// called when an observed attribute changes
}
static get observedAttributes() {
// list which attributes trigger attributeChangedCallback
return ['title', 'variant'];
}
}Key rule of thumb: do real work in connectedCallback, not the constructor. The constructor runs before the element is attached to the page and before attributes are necessarily set.
Reacting to attributes vs. properties
Custom elements can be configured two ways — as HTML attributes (<my-card variant="warning">) or as JS properties (el.variant = 'warning'). It’s common practice to keep them in sync:
class MyCard extends HTMLElement {
static get observedAttributes() { return ['variant']; }
get variant() { return this.getAttribute('variant'); }
set variant(val) { this.setAttribute('variant', val); }
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'variant') this.render();
}
}2. Shadow DOM
Shadow DOM attaches a separate, encapsulated DOM tree to an element. Styles and scripts inside it don’t leak out, and outer page styles don’t leak in (with some exceptions, see below). This is the same mechanism the browser itself uses internally for things like <video> controls or <input type="range">.
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }); // 'open' = accessible via el.shadowRoot
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
.card { border: 1px solid #ccc; padding: 1rem; }
</style>
<div class="card"><slot></slot></div>
`;
}
}mode: 'open'—element.shadowRootis accessible from outside JS. Use this in almost all cases.mode: 'closed'—element.shadowRootreturnsnullexternally. Rarely needed; mainly used by some browser built-ins.
What actually crosses the shadow boundary
- CSS written inside the shadow root does not affect the outside page, and outside page CSS does not affect elements inside the shadow root — this is the core encapsulation guarantee.
- CSS custom properties (variables) do inherit through the shadow boundary. This is the sanctioned way to let a page theme a component: define
--card-bgoutside, readvar(--card-bg)inside. - Global/inherited CSS properties like
font-familyandcoloralso inherit through by default (same as normal DOM inheritance), unless overridden inside the shadow root.
3. <template> and <slot>
<template>
Content inside <template> is inert — not rendered, not run — until you clone it into the live DOM. This is the standard way to define a component’s markup once and stamp it out efficiently.
<template id="card-template">
<style>
.card { border: 1px solid #ccc; padding: 1rem; }
</style>
<div class="card"><slot></slot></div>
</template>connectedCallback() {
const template = document.getElementById('card-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
}cloneNode(true) is important — it deep-clones the template’s content so each component instance gets its own copy rather than sharing/moving the same nodes.
<slot>
Slots let consumers of your component pass in their own content, which gets projected into a placeholder inside your shadow DOM:
<my-card>
<p>This content gets projected into the <slot></p>
</my-card>Named slots let you project into multiple specific positions:
<!-- inside the component's shadow DOM -->
<div class="header"><slot name="header"></slot></div>
<div class="body"><slot></slot></div><!-- usage -->
<my-card>
<span slot="header">Card Title</span>
<p>Body content goes into the default slot.</p>
</my-card>Loading templates/styles from separate files
If you want each component to live in its own HTML/CSS/JS files rather than inline template strings, you load and inject them asynchronously (there’s no native way to <link> a shadow-DOM stylesheet directly into a custom element without JS orchestration):
async function loadTemplate(url) {
const res = await fetch(url);
const html = await res.text();
const template = document.createElement('template');
template.innerHTML = html;
return template;
}
async function loadStylesheet(url) {
const res = await fetch(url);
const cssText = await res.text();
const sheet = new CSSStyleSheet();
await sheet.replace(cssText);
return sheet;
}CSSStyleSheet + adoptedStyleSheets (below) is the modern way to attach a stylesheet to a shadow root — more efficient than injecting a <style> tag when many instances share the same styles.
this.shadowRoot.adoptedStyleSheets = [sharedResetSheet, componentSheet];adoptedStyleSheets accepts an array — a shared reset/base stylesheet can be constructed once and reused across every component instance instead of being duplicated per instance.
Minimal end-to-end example
class MyCounter extends HTMLElement {
#count = 0;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.shadowRoot.querySelector('button')
.addEventListener('click', () => this.increment());
}
increment() {
this.#count++;
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>button { padding: 0.5rem 1rem; }</style>
<button>Count: ${this.#count}</button>
`;
}
}
customElements.define('my-counter', MyCounter);Note: re-setting innerHTML on every render (as above) is simple but destroys and recreates the button each time, which also drops the event listener — that’s why the listener is attached once in connectedCallback rather than inside render(). More sophisticated components use finer-grained DOM updates or a small diffing approach instead of full re-render, but that’s an optimization layer on top of these fundamentals, not part of the platform API itself.
Quick reference: common gotchas
- Tag names must contain a hyphen (
my-card, notcard). - Do setup work in
connectedCallback, not theconstructor— attributes/children aren’t guaranteed ready yet at construction time. observedAttributesmust be declared forattributeChangedCallbackto fire at all — unlisted attributes are silently ignored.- Shadow DOM blocks outside CSS except CSS custom properties and normally-inherited properties (
color,font-family, etc.) — use custom properties as your theming API. template.content.cloneNode(true)— don’t forget thetrue, or you’ll only clone the template element itself, not its contents.- Re-rendering by resetting
innerHTMLwipes out any event listeners attached to the replaced elements.