Skip to content
academia.sh

Lesson 15 / 24

Shadow DOM

A separate document tree opened inside an element; where the selector stops, style's two-way isolation, inheritance and custom properties crossing the boundary, open versus closed mode, and how an event's target gets retargeted.

Contents

The previous lesson gave the element a name and a lifecycle; it left its inside open. The measurement badge’s markup still sits in the document’s shared tree. A rule written anywhere on the page, .value { font-weight: 700 }, also catches the value inside the badge; the badge’s own rule leaks into another .value element on the page; a selector bound to the document finds the badge’s internal nodes and touches them.

What a reusable piece needs is the opposite. This lesson builds the mechanism that opens a separate tree inside the document tree and sets up two-way isolation.

Shadow Root, Host, and Light Tree

An element can acquire a second tree bound to it. This tree’s root is called the shadow root, the tree itself the shadow tree, and the element carrying the tree the host.

The shadow tree is not part of the document tree; it is a separate tree with its own root. The host is the single point where the two trees meet: an element in the document tree, a root owner in the shadow tree.

The host’s own children written in the document do not disappear; they stay where they are. These are called the light DOM. What is painted on an element with an attached shadow tree is not the light tree, it is the shadow tree; how the light tree’s content is recovered is the next lesson’s topic.

Attaching a shadow tree to an element is not possible for every tag. Elements with their own internal structure — form controls, an image, an input field — are closed to this; they already have an internal structure managed by the browser.

Where the Selector Stops

Isolation’s first face is the selector. A query made in the document tree does not descend into the shadow tree; a query made in the shadow tree does not climb out.

// shadow-boundary.mjs — the selector stops at the boundary, inheritance passes through it
const element = (name, attrs = {}, ...children) => {
  const node = { name, attrs, children, parent: null, shadow: null };
  for (const c of children) c.parent = node;
  return node;
};

// Attaches a shadow root to the host.
function attachShadow(host, ...children) {
  const root = { name: "#shadow-root", attrs: {}, children, parent: null, host, shadow: null };
  for (const c of children) c.parent = root;
  host.shadow = root;
  return root;
}

// Walks a tree; does NOT enter shadow trees it encounters.
function* walk(root) {
  yield root;
  for (const c of root.children) yield* walk(c);
}

const hasClass = (n, cls) => (n.attrs.class ?? "").split(/\s+/).includes(cls);
const format = (n) => `${n.name}#${n.attrs.id ?? "-"}`;
const query = (root, cls) => [...walk(root)].filter((n) => hasClass(n, cls)).map(format);

// Station page: two badges; the first has a shadow tree, the second is plain markup.
const badgeA = element("measurement-badge", { class: "badge", id: "temperature" });
attachShadow(badgeA,
  element("div", { class: "box", id: "shadow-box" },
    element("span", { class: "value", id: "shadow-value" }),
    element("span", { class: "unit", id: "shadow-unit" })));

const badgeB = element("p", { class: "badge", id: "humidity" },
  element("span", { class: "value", id: "light-value" }));

const doc = element("#document", { id: "document" }, element("section", { id: "dashboard" }, badgeA, badgeB));

console.log("from the document tree, .badge :", query(doc, "badge"));
console.log("from the document tree, .value :", query(doc, "value"));
console.log("from the shadow tree, .value   :", query(badgeA.shadow, "value"));
console.log("from the shadow tree, .badge   :", query(badgeA.shadow, "badge"));

// Inherited properties pass through the boundary; selector-based rules do not.
const INHERITED = new Set(["color", "font-size", "line-height"]);
const documentRule = { class: "value", declaration: { "font-weight": "700" } };
const fromHost = { color: "#b23", "font-weight": "400" };

function computeStyle(node) {
  const result = Object.fromEntries(
    Object.entries(fromHost).filter(([name]) => INHERITED.has(name)));
  const inDocumentTree = [...walk(doc)].includes(node);
  if (inDocumentTree && hasClass(node, documentRule.class)) Object.assign(result, documentRule.declaration);
  return result;
}

const shadowSpan = [...walk(badgeA.shadow)].find((n) => n.attrs.id === "shadow-value");
const documentSpan = [...walk(doc)].find((n) => n.attrs.id === "light-value");

console.log("computed span#light-value:", computeStyle(documentSpan));
console.log("computed span#shadow-value:", computeStyle(shadowSpan));
from the document tree, .badge : [ 'measurement-badge#temperature', 'p#humidity' ]
from the document tree, .value : [ 'span#light-value' ]
from the shadow tree, .value   : [ 'span#shadow-value' ]
from the shadow tree, .badge   : []
computed span#light-value: { color: '#b23', 'font-weight': '700' }
computed span#shadow-value: { color: '#b23' }

The first four lines show the boundary is symmetric. A query looking for .value in the document finds only one of two matches; span#shadow-value in the shadow tree is invisible. The reverse holds too: a query looking for .badge in the shadow tree returns empty even though the host itself has that class — the host is outside the shadow tree.

This has two practical consequences. First, ids in the shadow tree do not collide with document ids; every shadow tree has its own id space. Using the same component ten times on the page produces ten identical ids without any collision. Second, outside code that wants to touch the inside of a component can only do so by crossing from the host into the shadow root — that is, by a deliberate and visible step.

Style’s Two-Way Isolation

Isolation’s second face is style, and it follows from the same rule as the selector. The matching defined in the Cascade and Specificity lesson is a rule applying to elements in its own tree. A rule written in the document has a selector that does not match elements in the shadow tree; a rule written in the shadow tree does not match the document tree.

The output’s last two lines show this distinction. The document rule’s font-weight: 700 was applied to span#light-value in the document tree, and was not applied to span#shadow-value in the shadow tree.

There are two defined crossings on style’s side of the boundary, and both are deliberate.

The first is inheritance. Inherited properties do not recognize the tree boundary: the text color, line height, and font size applied to the host carry over into the shadow tree. This is why both elements in the output got their color value from the host. Shadow-tree style is therefore not a complete reset; the component adapts to typography coming from outside, and this is usually the wanted behavior.

The second is custom properties. Since they are defined as an inherited value, they cross the boundary and give the component an externally adjustable surface. The component reads a custom property with a fallback value in its own rules; the page changes the component’s appearance by defining that property on the host, without ever touching its internal structure. This mechanism, built in the Custom Properties lesson, becomes a component’s style contract together with the shadow tree.

Two selectors are defined for rules written from inside the shadow tree. One targets the host itself and styles the component’s outer box; it lets conditional style be given based on the host’s context in the document or an attribute. The other targets internal parts the component exposes outward: the component marks an internal element by giving it a name, and outside style uses that name to reach only the marked parts. This is not piercing encapsulation, it is opening a window defined within it — the component itself decides which of its parts are open to being styled.

Open and Closed Mode

One of two modes is chosen while attaching a shadow tree. In open mode, the host’s shadow root can be read by a program; outside code can reach into the component’s inside. In closed mode, the shadow root cannot be reached through the host; the reference is kept only in the component’s own code.

Closed mode is not a security boundary. The component’s code runs in the same environment as the page; determined code can reach the shadow root by other means. Closed mode is a statement of intent: the component’s internal structure is not part of its interface, and code relying on it will break.

It has a cost too. A closed tree makes the page’s accessibility checks, testing tools, and debugging harder. The common choice is open mode; encapsulation is protected by contract, not by an access barrier.

Events Crossing the Boundary

The Event Model lesson defined the propagation path as the ancestor chain running from root to target. The shadow tree adds one thing to this chain: the path does not stop at the shadow root, it jumps to the host and continues up to the document. This is called the composed path.

A problem arises when the path spills outward. If a listener bound to the document sees a node inside the shadow tree as the event’s target, encapsulation has been pierced. This is why the browser retargets the target: every listener sees, as the event’s target, the nearest counterpart in its own tree.

// retargeting.mjs — how the target changes as an event crosses the shadow boundary
const element = (name, ...children) => {
  const node = { name, children, parent: null, host: null, shadow: null };
  for (const c of children) c.parent = node;
  return node;
};

function attachShadow(host, ...children) {
  const root = { name: "#shadow-root", children, parent: null, host, shadow: null };
  for (const c of children) c.parent = root;
  host.shadow = root;
  return root;
}

// The root of the tree a node belongs to: either the document or a shadow root.
const treeRoot = (n) => { let a = n; while (a.parent) a = a.parent; return a; };

// Composed path: starts from the target, jumps to the host at tree roots, climbs to the document.
function composedPath(target) {
  const path = [];
  for (let n = target; n; n = n.parent ?? n.host) path.push(n);
  return path;
}

// Target for a listener: the first node in the composed path that is in the SAME tree as the listener.
function retargetedTarget(target, listener) {
  const listenerTree = treeRoot(listener);
  return composedPath(target).find((n) => treeRoot(n) === listenerTree);
}

const button = element("button.save");
const box = element("div.box", button);
const badge = element("measurement-badge");
const shadowRoot = attachShadow(badge, box);
const dashboard = element("section#dashboard", badge);
const doc = element("#document", dashboard);

console.log("composed path:", composedPath(button).map((n) => n.name).join(" -> "));
console.log();
console.log("listener's node        target it sees");
for (const n of [button, box, shadowRoot, badge, dashboard, doc])
  console.log(n.name.padEnd(22), retargetedTarget(button, n).name);

// An event with composed: false does not climb past the shadow root.
const propagationPath = (target, composed) => {
  const path = composedPath(target);
  if (composed) return path;
  const boundary = path.findIndex((n) => n.name === "#shadow-root");
  return boundary === -1 ? path : path.slice(0, boundary + 1);
};

console.log();
console.log("composed: true  ->", propagationPath(button, true).map((n) => n.name).join(" -> "));
console.log("composed: false ->", propagationPath(button, false).map((n) => n.name).join(" -> "));
composed path: button.save -> div.box -> #shadow-root -> measurement-badge -> section#dashboard -> #document

listener's node        target it sees
button.save            button.save
div.box                button.save
#shadow-root           button.save
measurement-badge      measurement-badge
section#dashboard      measurement-badge
#document              measurement-badge
composed: true  -> button.save -> div.box -> #shadow-root -> measurement-badge -> section#dashboard -> #document
composed: false -> button.save -> div.box -> #shadow-root

The three listeners inside the shadow tree see the real target; the three outside it see the host. Event-delegation code bound to the document cannot learn which button was clicked inside the component; it only gets the information “the measurement badge was clicked.” The component itself decides what to announce outward, and it usually does this with an event type it defines itself.

The last two lines show the second distinction. Whether an event crosses the shadow boundary at all is set by a field on the event object. An event that does not cross the boundary stops at the shadow root and never reaches outside. Built-in events arising from user input cross the boundary; events a component defines itself do not cross by default, and if crossing outward is wanted, this is declared explicitly.

Summary

  • A shadow tree is a separate document tree bound to a host element; the host’s own children in the document stay in place as the light tree.
  • The selector does not cross the boundary in either direction: a document query cannot see the shadow tree, a shadow query cannot see the document tree. Id spaces are separate too.
  • Style matching follows the same boundary as the selector; inherited properties and custom properties cross the boundary and form the component’s style contract.
  • A component opens its internal parts to outside styling by giving them a name; encapsulation is preserved outside these windows.
  • Closed mode makes access harder but is not a security boundary; what it carries is that the internal structure is not part of the interface.
  • The event path jumps from the shadow root to the host and continues; every listener sees the target as its counterpart in its own tree, and events that do not cross the boundary stop at the shadow root.

Next Step

This lesson showed how the shadow tree is isolated, but not where its content comes from. Two questions were left open. First, if the component’s internal structure is built node by node for every instance, fifteen badges run the same setup code fifteen times; markup needs a form that sits unrendered and can be cheaply duplicated. Second, the host’s light tree written in the document is nowhere visible right now; the component needs to place the content it receives from outside at a point the shadow tree determines. The next lesson builds these two mechanisms together — an unrendered markup container, and the points where content gets placed.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close