Web Components: custom elements and shadow DOM
Define a custom element, use its lifecycle callbacks correctly, encapsulate styles in a shadow root, and compose with slots and events.
Defining a custom element
class RatingStars extends HTMLElement {
static observedAttributes = ['value'];
constructor() {
super();
// do not read attributes or children here - they may not exist yet
}
connectedCallback() {
if (!this.shadowRoot) this.#render();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (this.shadowRoot) this.#update(newValue);
}
#render() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML =
'<style>:host { display: inline-flex; gap: 2px; }</style>' +
'<span part="stars"></span>';
this.#update(this.getAttribute('value') ?? '0');
}
#update(value) {
const n = Math.max(0, Math.min(5, Number(value) || 0));
this.shadowRoot.querySelector('[part=stars]').textContent = '*'.repeat(n);
}
}
customElements.define('rating-stars', RatingStars);<rating-stars value="3"></rating-stars>- A custom element name must contain a hyphen, which is what keeps it from colliding with a future HTML element.
customElements.definethrows if the name is already registered, so guard in hot-reload environments.- The constructor must not inspect attributes or children - they are not parsed yet. Render in
connectedCallback. attributeChangedCallbackonly fires for names listed inobservedAttributes.
Shadow root, styles and slots
<template id="card">
<style>
:host { display: block; border: 1px solid var(--border, #ddd); }
:host([disabled]) { opacity: 0.5; pointer-events: none; }
.title { font-weight: 600; }
::slotted(img) { border-radius: 6px; }
</style>
<div class="title"><slot name="title">Untitled</slot></div>
<div class="body"><slot></slot></div>
</template>class SummaryCard extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.append(document.querySelector('#card').content.cloneNode(true));
}
get icons() {
return this.shadowRoot.querySelector('slot[name="icon"]').assignedElements();
}
}| Selector | Scope | Applies to |
|---|---|---|
:host | Inside the shadow root | The custom element itself |
:host([disabled]) | Inside | The element, when the attribute is present |
::slotted(sel) | Inside | Light-DOM children placed in a slot |
::part(name) | Outside | Themed from the page via part |
:root custom properties | Inherited in | The supported theming channel |
💡
Styles do not leak in and selectors do not leak out, but inherited properties and CSS custom properties still cross the boundary. That is not a loophole - it is the intended theming API, and it is why theming a web component is done with custom properties rather than by overriding its internal classes.
Events and composition
class QuantityPicker extends HTMLElement {
#input;
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<button data-step="-1">-</button>' +
'<input value="1" inputmode="numeric">' +
'<button data-step="1">+</button>';
this.#input = root.querySelector('input');
// retargeted events bring the click out of the shadow root
root.addEventListener('click', (event) => {
const button = event.target.closest('[data-step]');
if (!button) return;
const next = Math.max(1, Number(this.#input.value) + Number(button.dataset.step));
this.#input.value = String(next);
this.dispatchEvent(new CustomEvent('quantity-change', {
detail: { quantity: next },
bubbles: true,
composed: true,
}));
});
}
get value() { return Number(this.#input.value); }
set value(v) { this.#input.value = String(v); }
}
customElements.define('quantity-picker', QuantityPicker);composed: trueis what lets an event escape the shadow root; without it the event stops at the boundary.- Inside a shadow root,
event.targetis retargeted to the host element for listeners outside - usecomposedPath()when you need the real source. - Prefer properties over attributes for values that are not strings: attributes are strings and always will be.
- Reflect a property back to an attribute only when CSS needs to match on it, such as
:host([disabled]).
// form-associated custom elements participate in FormData
class MoneyInput extends HTMLElement {
static formAssociated = true;
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
set value(v) {
this.#internals.setFormValue(v);
}
}FAQ
Open or closed shadow root?
Open, unless you have a specific reason.
closed prevents element.shadowRoot from working, which breaks debugging, testing and any tool that needs to inspect the tree - and it does not stop a determined script, because the element still exposes the same internals through other paths.Why is my custom element not upgrading?
The definition ran after the element was parsed, or the tag name does not match. Elements are upgraded automatically once
define is called, but anything you moved with innerHTML and did not reconnect stays un-upgraded. Use customElements.whenDefined(name) when ordering matters.Related
Events and delegation in depth Observing changes with MutationObserver
Last refreshed 2026-09-18.