---
title: 'Shadow DOM'
source: 'https://academia.sh/en/courses/browser-platform/shadow-dom'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:12+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
