JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Web Components: custom elements and shadow DOM
Define your own HTML tags with lifecycle callbacks and give them a shadow root that scopes markup and CSS while accepting page content through slots.
What you will learn
- Register a dashed tag name with customElements.define and a class extending HTMLElement
- Use observedAttributes with attributeChangedCallback to react to attribute changes
- Attach a shadow root so ids and CSS selectors stay scoped inside your element
- Project page-supplied children into the shadow tree with named and default slots
Understanding Web Components: custom elements and shadow DOM
customElements.define('greet-box', GreetBox) writes a name into a registry that the HTML parser and document.createElement both consult, so every <greet-box> in the page becomes an instance of your class, and any that were already sitting in the document get upgraded in place. Because the browser decides when instances are constructed, the constructor runs at an awkward moment: for parser-created elements it runs before the attributes have been applied and before the children have been parsed, which is why the spec forbids touching attributes or child nodes there and hands you connectedCallback and attributeChangedCallback instead. The dash in the name is not a style rule, it is the guarantee that your tag can never collide with a future built-in element, and define() throws if you omit it.
attachShadow gives the element a second, private tree that takes over rendering: whatever the page put inside the tag (its light DOM) stops being displayed unless a <slot> in the shadow tree pulls it in. The boundary blocks selector matching in both directions, so document.querySelector cannot see inside, a page rule for p never matches your inner p, and ten instances can all reuse id="label" without clashing. What does cross is inheritance: color, font, and CSS custom properties flow from the host into the shadow tree, which is deliberate, because it lets the page influence appearance without touching your structure.
The useful mental model is a contract. The public side is the tag name, its attributes, its JavaScript properties, the events it dispatches, the children it accepts in slots, and the styling hooks you deliberately expose such as custom properties and ::part. Everything in the shadow tree is an implementation detail you are free to rewrite, so state belongs in reflected attributes and properties, and notifications belong in dispatched events, rather than in the assumption that the page will reach in and poke your internals.
class GreetBox extends HTMLElement {
static get observedAttributes() { return ['name']; }
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML =
'<style>p { margin: 0; font-weight: bold }</style>' +
'<p>Hello, <span class="who">world</span>!</p>';
}
connectedCallback() {
console.log('connected, name attr =', this.getAttribute('name'));
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`attr ${name}: ${oldValue} -> ${newValue}`);
this.shadowRoot.querySelector('.who').textContent = newValue ?? 'world';
}
}
customElements.define('greet-box', GreetBox);
const box = document.createElement('greet-box');
box.setAttribute('name', 'Ada');
document.body.append(box);
console.log('document.querySelector(".who"):', document.querySelector('.who'));
console.log('shadowRoot text:', box.shadowRoot.querySelector('.who').textContent);
box.setAttribute('name', 'Grace');A custom element binds a tag name to a class that the browser instantiates and calls back into, and its shadow root is a separate tree that blocks page selectors and id lookups while still inheriting style values through the host.
Worked examples
Slots project light DOM without moving it
Shows how children supplied by the page are assigned to named and default slots while staying children of the host.
class InfoCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML =
'<h2><slot name="title">Untitled</slot></h2><div><slot></slot></div>';
}
}
customElements.define('info-card', InfoCard);
const card = document.createElement('info-card');
card.innerHTML = '<span slot="title">Scoped</span><p>one</p><p>two</p>';
document.body.append(card);
const titleSlot = card.shadowRoot.querySelector('slot[name="title"]');
const bodySlot = card.shadowRoot.querySelector('slot:not([name])');
console.log('title slot shows:', titleSlot.assignedNodes().map(n => n.textContent).join(''));
console.log('body slot count:', bodySlot.assignedNodes().length);
console.log('light children:', card.children.length, '| shadow children:', card.shadowRoot.children.length);
console.log('first p assignedSlot is body slot:', card.querySelector('p').assignedSlot === bodySlot);Example explained
Line 1The text Untitled inside the named slot is fallback content: it renders only while nothing in the light DOM carries slot="title".
Line 2assignedNodes() reports the light DOM nodes the browser matched to that slot, so the span outside the shadow tree is listed by a slot inside it.
Line 3card.children.length stays 3 while the shadow root has only h2 and div, proving slotting projects nodes for rendering instead of relocating them.
Line 4assignedSlot is the reverse lookup, from a page-supplied child back to the slot currently displaying it.
Selectors stop at the boundary, inheritance does not
Demonstrates that a document rule cannot match shadow content, yet an inherited value from body still reaches it.
const pageStyle = document.createElement('style');
pageStyle.textContent = 'body { color: rgb(0, 128, 0) } p { color: rgb(255, 0, 0) }';
document.head.append(pageStyle);
class TwoLines extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = '<p>inside p</p><span>inside span</span>';
}
}
customElements.define('two-lines', TwoLines);
const pagePara = document.createElement('p');
const widget = document.createElement('two-lines');
document.body.append(pagePara, widget);
console.log('page p:', getComputedStyle(pagePara).color);
console.log('shadow p:', getComputedStyle(widget.shadowRoot.querySelector('p')).color);
console.log('shadow span:', getComputedStyle(widget.shadowRoot.querySelector('span')).color);Example explained
Line 1The document rule for p matches only the paragraph in the main tree; selector matching never descends through a shadow root.
Line 2The shadow p has no color declaration of its own, so color is inherited from its parent chain, which continues out through the host element.
Line 3That chain reaches body, so the green inherited value arrives inside while the red rule aimed straight at p does not.
Line 4The span behaves identically, which shows the effect comes from inheritance rather than from anything specific to paragraphs.
Important notes
mode: 'closed' only makes host.shadowRoot return null; it is an encapsulation convenience, not a security boundary, since any code holding the root reference can still rewrite the tree.
Events bubbling out of a shadow root are retargeted, so a page listener sees event.target as the host; events created with composed: false never leave the root, and composedPath() is how you inspect the real inner target.
Common mistakes
Registering a one-word name such as customElements.define('card', Card): define() throws a SyntaxError and the tag remains an unstyled unknown element.
Calling this.setAttribute() or this.append() on the host inside the constructor: creating the element then fails with NotSupportedError, because a custom element must not gain attributes or children while being constructed.
Trying to reach shadow content with document.querySelector or a page-wide rule: the query returns null and the CSS silently does nothing, so you must go through el.shadowRoot and put the styles inside the root.
Try it yourself
Change, predict, then run
Build a toggle-tip element whose shadow root holds a button and a slot that starts hidden, so clicking the button flips an open attribute on the host and attributeChangedCallback shows or hides the slot. Verify from the console that document.querySelector cannot find the inner button.
Open the JavaScript workspaceCheck your understanding
A page defines body { color: green } and p { color: red }. A paragraph inside a custom element's shadow root, which has no styles of its own, renders green. Why?
- A shadow root's styles always outrank page styles, so the red rule loses the cascade
- Page rules apply only to elements built by the HTML parser, and shadow content was built by script
- Document selectors do not match elements inside a shadow tree, but inherited property values still flow in through the host
- Only :host and ::part rules cross the boundary, so type selectors like p are dropped from page stylesheets
Show answer
Encapsulation blocks selector matching, not inheritance: the p rule never matches the shadow paragraph, while color inherits from body through the host into the shadow tree. The first option is tempting but wrong twice over, since this shadow root contains no color declaration at all, and when a page rule does target the host element its declarations beat the shadow tree's own :host rules.