Web Platform · DOM and Events

Shadow DOM Boundaries and Event Retargeting

Events leaving a shadow tree carry a different target for each listener on the path, and the composed flag decides whether the path crosses the boundary at all.

A component built with a shadow root has an inside that its users are not supposed to address directly. Events break that separation by nature: a click lands on an internal button, and the code outside the component has a legitimate interest in knowing that something inside was clicked. The platform resolves the tension by keeping one event and one path, but giving each path entry its own answer to the question “what was the target?” The answer outside the component is the host element, never the internal button.

Two trees over the same subtree

A shadow root is a node whose parent is null but whose host is an element, so the single-parent rule from node identity and structural mutation holds without making the shadow tree a child of the host. element.attachShadow({ mode: 'open' }) creates it and returns it; mode: 'closed' creates it without exposing it through element.shadowRoot, which stays null for outside code. The mode governs the accessor, nothing else: the tree still exists, still renders, and still participates in events.

getRootNode() returns the nearest root, which is the shadow root for a node inside one; getRootNode({ composed: true }) skips shadow roots and returns the document. The same distinction appears in every tree walk in the platform: the node tree stops at a shadow boundary, while the shadow-including tree continues through the host.

Slots add a second indirection. Light DOM children of a host are not moved into the shadow tree; they are assigned to slot elements by name, and the flattened tree interleaves them for rendering. A slotted node’s parent pointer still points at the host, but its flattened position is inside the shadow tree. That dual identity is what makes event paths through slotted content look surprising.

Composed decides whether the path crosses

An event’s composed attribute, fixed at construction, controls whether path construction continues past a shadow root. When it is true, the path includes the shadow root’s host and then the host’s ancestors. When it is false, the path ends at the shadow root: listeners on the host and above never see the event at all, in either phase, because they are not on the path.

Most user interface events are composed, because a click on a component’s internal button must be observable by the application. slotchange is not composed: it describes the assignment of slotted content and is delivered within the tree that owns the slot. Script-created events default to composed: false, so a new CustomEvent('select') dispatched inside a component stays inside it unless the constructor says otherwise.

Bubbling and composition are independent, and the path each listener sees is assembled exactly as event propagation describes. An event can be composed without bubbling, in which case it crosses the boundary during the capture pass; the host is then treated as a target in its own right, so its listeners see AT_TARGET in both passes, while ancestors above the host see only the capture pass. The two flags are often set together but nothing requires it.

Retargeting, entry by entry

Path construction records, for each entry, a shadow-adjusted target. The rule is a short loop: take the event’s target; while the target’s root is a shadow root and the entry’s node is not in that root, replace the target with the root’s host; repeat. The result is the innermost node that the listener at that entry is allowed to see.

class FancyButton extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = '<button part="button">Press</button>';
    root.querySelector('button').addEventListener('click', (event) => {
      console.log('inside target:', event.target.localName); // button
    });
  }
}

customElements.define('fancy-button', FancyButton);

document.addEventListener('click', (event) => {
  console.log('outside target:', event.target.localName); // fancy-button
  console.log('path:', event.composedPath()); // open root: the internal button is visible here
});

Retargeting is not limited to target. relatedTarget on mouseover and mouseout is retargeted by the same rule, which prevents a component from revealing that the pointer came from an internal element. composedPath() returns the full path, but filtered by the current listener’s position: from outside a closed shadow root, the internal nodes are omitted, so the array starts at the host. From inside, the same call returns the internal nodes as well. A component cannot use composedPath() to unmask a closed sibling’s internals, but it can inspect its own.

Where slotted content sits on the path

An event on a slotted node travels through the slot, not around it. Path construction substitutes the assigned slot for the parent link, then continues from the slot into the shadow tree, then to the host, then into the light tree. Two consequences follow.

First, a listener inside the shadow tree receives events that originated on content it does not own. A component can therefore observe interaction with its slotted children without asking the application to wire anything up. Second, the retargeting rule still applies at each boundary, so the listener inside the shadow tree sees the slotted node as the target, while a listener outside the component sees the host. The event is one object moving through two different views of the same structure.

When slotted content is reassigned, slotchange fires on the slot element, bubbling within the shadow tree. The event’s target is the slot, which is not something outside code can name, which is consistent with the event not being composed.

Focus delegation as a boundary option

attachShadow accepts delegatesFocus alongside mode. With the option set, calling focus() on the host forwards focus to the first focusable descendant in the shadow tree, tabbing into the host lands on that descendant. Without it, calling focus() on a host that is not itself focusable does nothing, and the internal control is reached only through its own tab stop.

The option is fixed at shadow root creation, like mode. Components that wrap a single native control, such as a styled button or an input with decorations, need it to behave like the control they replace; components that contain several interactive regions usually do not, because forwarding focus to an arbitrary first descendant produces a tab order that does not match the visual layout.

Focus, hit testing, and the host as proxy

document.activeElement reports the host when focus is inside its shadow tree, since the internal element is not addressable from the document. shadowRoot.activeElement gives the internal node for an open root. The :focus-within pseudo-class on the host works for the same reason: the focus is inside its subtree in the shadow-including sense.

Hit testing follows the flattened tree. document.elementFromPoint returns the host for a point over internal content; shadowRoot.elementFromPoint on an open root returns the inner element. This is the same retargeting principle applied to geometry rather than events, and it is why components that need to measure internal elements must do so from inside.

Custom events and the shape of what escapes

A component that needs to report a semantic action rather than a raw click has to choose three flags together: bubbles, composed, and the payload. The payload travels by reference in detail; it is not cloned or serialised, so an object handed to the constructor remains the same object at every listener. Retargeting rewrites target and relatedTarget and leaves detail alone, which means anything placed in detail is visible outside the shadow tree regardless of mode. A closed shadow root that puts an internal element into detail hands that element to the application.

The safe payload is data the component’s public API already exposes: an identifier, a selected value, a numeric index. Emitting an internal node or a mutable internal object converts an encapsulation boundary into a suggestion. Because the event crosses synchronously, listeners can also mutate detail before later listeners run, so a producer that reads detail after dispatch observes whatever the listeners did to it.

Retargeting is encapsulation, not security

Closed mode hides the tree from element.shadowRoot and filters composedPath(), which resists casual inspection and accidental coupling. It does not prevent an event from crossing the boundary, does not prevent timing differences, and does not isolate styles from inheritance. Any code that can run on the page can observe that clicks on the host occur and how the component reacts.

That is the intended division. mode: 'closed' is a statement about API surface; the security boundary in the platform is the origin, not the shadow root. Treating a closed shadow root as a place to keep secrets produces designs that break when the component is inspected with developer tools or when a pointer event’s coordinates reveal more than the event’s target.

A practical consequence for event design: decide composed before the component ships, because it is not mutable after construction. A non-composed event cannot be made to escape later without changing the producer. Components that dispatch their own semantic events should compose them, keep bubbles true, and rely on retargeting to present a clean face outward; the alternative, dispatching on the host directly, loses the internal target for internal listeners and makes the component’s own event handling inconsistent with the platform’s.

References

  1. DOM Standard: Shadow trees — WHATWG
  2. HTML Standard: The slot element — WHATWG
  3. Using shadow DOM — MDN Web Docs