Web Components and Shadow DOM
Web Components are browser standards for creating your own HTML elements without a framework:
- Custom elements:
customElements.define('my-counter', MyCounter)registers a class that extendsHTMLElement, with lifecycle callbacks. - Shadow DOM: a hidden DOM tree attached to an element. Its markup and styles are encapsulated: page CSS does not reach inside, its CSS does not leak out.
<template>and<slot>: inert markup to clone, and placeholders where the element's children (the light DOM) are shown.
They work in any framework or none, which makes them a fit for design systems shared between teams and for widgets embedded in other sites.
Details
Custom Elements
- The name must contain a hyphen and start with a lowercase letter
(
user-card), so it never clashes with a future HTML element. - Lifecycle:
constructor(callsuper()first; don't read attributes or children: they may not exist yet),connectedCallback(inserted into the document; may run again after a move),disconnectedCallback(removed: clean up listeners and timers),attributeChangedCallback(name, oldValue, newValue)for the attributes listed instatic observedAttributes,adoptedCallback(moved to another document). - Elements in the HTML before
define()are upgraded when it runs; until then they match:not(:defined), which is used to hide them, andcustomElements.whenDefined(name)returns a promise. - Autonomous elements extend
HTMLElement(<my-button>). Customized built-ins extend a native element (<button is="my-button">), but not every browser supports them, so in practice only autonomous elements are used. - Attributes are strings; rich data (objects, arrays) is passed through properties. A common pattern is to reflect simple properties to attributes.
Shadow DOM
this.attachShadow({ mode: 'open' })creates the shadow root.openexposes it aselement.shadowRoot;closedreturnsnullthere.closedis not a security boundary: it only makes testing and styling harder.- What crosses the shadow boundary:
| Crosses | Stays on its side |
|---|---|
Inherited properties: color, font, line-height | Page selectors: .button does not match inside |
CSS custom properties (--accent): the main theming tool | Page ids: getElementById, <label for> |
Elements marked with part="label", styled from outside with ::part(label) | Shadow styles do not leak out to the page |
| Events, retargeted to the host | ARIA ID references (aria-labelledby, aria-describedby) |
- Inside:
:hoststyles the element itself (:host([disabled])by an attribute),::slotted(p)styles the slotted children (only top-level ones). Page styles for slotted children win, because those children stay in the light DOM. - Constructed stylesheets:
new CSSStyleSheet()+replaceSync(css), assigned toshadowRoot.adoptedStyleSheets, are shared by all instances instead of a<style>copy in each. - Events that come out of the shadow tree are retargeted to the host. A
custom event crosses the boundary only with
composed: true(andbubbles: trueto bubble). See DOM Events.
Declarative Shadow DOM
<template shadowrootmode="open">inside an element makes the HTML parser attach the shadow root, without JavaScript. This is how Web Components are server-rendered: the content is visible before the script loads, and the class then hydrates it.- It works only in parsed HTML:
innerHTMLignores it, whilesetHTMLUnsafe()andDocument.parseHTMLUnsafe()support it.
Forms and Accessibility
- A custom input does not take part in a
<form>by itself.static formAssociated = trueandthis.internals = this.attachInternals()make it one:internals.setFormValue(),internals.setValidity(),<label>andform.reset()work. ElementInternalsalso sets the default role and ARIA properties (internals.role = 'button') without attributes on the host.- ARIA references by
iddo not cross the shadow boundary in either direction. Keep the label and the control in the same tree, or usearia-label. See Web Accessibility.
With Frameworks
- React 19 sets props on a custom element as properties when the element has
such a property, and listens to custom events through
on+ the exact event name (oncount-changeforcount-change). See What's New in React 19. - Lit (a thin library) and Stencil (a compiler) add reactive properties and templating on top of the standard.
- Scoped registries (
new CustomElementRegistry()) let two versions of one element coexist on a page.
Support: custom elements, Shadow DOM, <template>, <slot>, ::part,
constructed stylesheets, form-associated elements and Declarative Shadow DOM are
Baseline; customized built-ins are not in Safari, scoped registries not in
Firefox.
Example
A counter with a shadow root, a slot for the label and an observed attribute:
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { display: inline-flex; gap: 0.5em; align-items: center; }
button { color: var(--counter-accent, royalblue); }
`);
class MyCounter extends HTMLElement {
static observedAttributes = ['count'];
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.adoptedStyleSheets = [sheet];
root.innerHTML = `
<slot>Count</slot>: <output part="value"></output>
<button type="button">+1</button>
`;
root.querySelector('button').addEventListener('click', () => {
this.count += 1;
// Dispatched on the host, so it needs no `composed: true`
this.dispatchEvent(
new CustomEvent('count-change', { detail: this.count, bubbles: true })
);
});
}
get count() {
return Number(this.getAttribute('count') ?? 0);
}
set count(value) {
this.setAttribute('count', String(value)); // reflects to the attribute
}
attributeChangedCallback(name, oldValue, newValue) {
this.shadowRoot.querySelector('output').textContent = newValue;
}
connectedCallback() {
this.shadowRoot.querySelector('output').textContent = this.count;
}
}
customElements.define('my-counter', MyCounter);
<style>
my-counter {
--counter-accent: crimson; /* custom properties get inside */
}
my-counter::part(value) {
font-weight: bold;
}
</style>
<my-counter count="3">Clicks</my-counter>
Follow-up Questions
- Shadow DOM or CSS Modules for isolation? CSS Modules only rename classes at
build time; Shadow DOM isolates in the browser in both directions, but makes
theming harder: only inherited properties, custom properties and
::partget in. - Why can a custom element not read its attributes in the constructor? When it
is created with
document.createElement, it has none yet. Read them inconnectedCallbackorattributeChangedCallback. - What happens to
document.querySelectorfor elements inside a shadow root? It does not find them; queryelement.shadowRootinstead. - Can Shadow DOM be used without a custom element? Yes:
attachShadow()works ondiv,span,section,pand other containers (not on<a>,<img>or<input>). Third-party widgets use it to isolate their styles. - When are Web Components worse than React components? Inside a single React app: passing data, composing and server rendering are simpler with the framework's own components.
Common Mistakes
- A name without a hyphen:
define()throwsSyntaxError. - Passing objects through attributes: they become
"[object Object]". - Not cleaning up in
disconnectedCallback: listeners onwindowand timers keep working after the element is removed. - Expecting page CSS classes to style the inside of the component.
- Dispatching a custom event without
composed: truefrom inside the shadow tree and wondering why the page does not get it.