Retargeting Events with composedPath(): Why event.target Lies Outside the Boundary
A click on a button inside a shadow tree, observed from a listener on document, reports event.target as the host element — not the button. This is not a bug and not an approximation: the DOM Standard calls it retargeting, and it exists so that encapsulation survives event propagation. A page listening at the document level should see “a click happened on <media-card>”, not “a click happened on the third <button> inside <media-card>'s shadow tree”, because the second fact is an implementation detail the card never promised.
The consequence is that every outside-click dismisser, every event-delegation table, and every analytics handler written with event.target and contains() gets the wrong answer as soon as a custom element is involved. composedPath() is the supported way to ask a more precise question, with a defined limit on how much it reveals. This deep dive belongs to Event Composition & Bubbling inside Core Architecture & Lifecycle Management.
Problem Statement: The Dismisser That Closes on Its Own Clicks
class DropdownMenu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>:host { display: inline-block; position: relative; }</style>
<button id="trigger">Open</button>
<div id="panel" hidden>
<button data-action="rename">Rename</button>
<button data-action="delete">Delete</button>
</div>`;
}
connectedCallback() {
this.shadowRoot.getElementById('trigger').addEventListener('click', () => {
this.shadowRoot.getElementById('panel').hidden = false;
});
document.addEventListener('click', (event) => {
// BUG: event.target is retargeted to the host for shadow-tree clicks,
// and contains() only walks the LIGHT tree.
if (!this.contains(event.target)) {
this.shadowRoot.getElementById('panel').hidden = true;
}
});
}
}
customElements.define('dropdown-menu', DropdownMenu);
Click “Open” and the panel appears, then closes immediately. The document listener runs for the same click; event.target is the <dropdown-menu> host, and this.contains(host) is false because an element does not contain itself in the sense the code intends — worse, when the menu is nested inside another component, contains fails for a different reason: the retargeted target is that outer host, which really is not a descendant.
Clicking “Delete” is the same story. The handler that should run never sees data-action="delete", because by the time the click reaches document the target has been rewritten to the host.
Root-Cause Analysis
The DOM Standard specifies event dispatch as building an event path — an ordered list of every object the event will visit, from the original target outward to the window — and then, for each entry, computing a retargeted target relative to where the listener lives. The rule is: report the ancestor in the listener’s own tree, so a listener never learns about nodes in a shadow tree it cannot access.
Retargeting applies to event.target, event.relatedTarget, and the selection endpoints, and it applies at every boundary, not only the outermost. An event originating three shadow trees deep is reported to the middle tree as the inner host and to the document as the outer host.
composedPath() returns the retargeted-free view: the full array of nodes the event traverses, ordered from the original target outward. Two rules govern how much it shows.
It stops at closed roots the caller cannot see. For an event that passed through a mode: 'closed' shadow root, the path returned to a listener outside that root omits the nodes inside it — beginning instead at the closed host. This is what makes closed mode a real privacy boundary, and it is one of the concrete costs weighed in open vs closed shadow DOM tradeoffs.
It is only populated during dispatch. Calling composedPath() after the event has finished — from a setTimeout, or from a stored event object — returns an empty array. Anything derived from the path must be computed synchronously in the handler.
There is a prior condition that catches people before retargeting does: an event only leaves the shadow tree at all if it is composed. Most UI events are (click, pointerdown, focusin, keydown), several are not (mouseenter, mouseleave, and by default any CustomEvent). A custom event dispatched without composed: true never reaches a document listener, so no amount of composedPath() will find it — the contract described in composing custom events across shadow boundaries.
contains() and closest() do not cross the boundary. Both walk the node tree, and a shadow tree is a separate tree, so host.contains(innerButton) is false and innerButton.closest('dropdown-menu') is null. Replacing event.target with composedPath()[0] and then calling contains() on it fixes nothing — the containment test is the second half of the same mistake. Test membership with composedPath().includes(element) instead.
Production-Safe Pattern
class DropdownMenu extends HTMLElement {
#controller = null;
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>:host { display: inline-block; position: relative; }</style>
<button id="trigger" aria-expanded="false">Open</button>
<div id="panel" hidden role="menu">
<button data-action="rename" role="menuitem">Rename</button>
<button data-action="delete" role="menuitem">Delete</button>
</div>`;
}
connectedCallback() {
this.#controller = new AbortController();
const { signal } = this.#controller;
const trigger = this.shadowRoot.getElementById('trigger');
trigger.addEventListener('click', () => this.#setOpen(true), { signal });
// Inside the shadow tree there is no retargeting, so target is precise.
this.shadowRoot.getElementById('panel').addEventListener('click', (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
if (!action) return;
this.#setOpen(false);
this.dispatchEvent(new CustomEvent('menu-action', {
detail: { action }, bubbles: true, composed: true
}));
}, { signal });
// Outside-click dismissal: membership is a PATH question, not a tree question.
document.addEventListener('click', (event) => {
if (event.composedPath().includes(this)) return; // the click was ours
this.#setOpen(false);
}, { signal, capture: true });
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') this.#setOpen(false);
}, { signal });
}
disconnectedCallback() {
this.#controller?.abort();
this.#controller = null;
}
#setOpen(open) {
this.shadowRoot.getElementById('panel').hidden = !open;
this.shadowRoot.getElementById('trigger').setAttribute('aria-expanded', String(open));
}
}
customElements.define('dropdown-menu', DropdownMenu);
// Application-level delegation, one listener for a whole page of components.
document.addEventListener('click', (event) => {
// The first entry is the true innermost target the caller is allowed to see.
const origin = event.composedPath()[0];
// Walk the path rather than the tree: closest() stops at each boundary.
const actionable = event.composedPath().find(
(node) => node instanceof Element && node.hasAttribute('data-analytics')
);
if (actionable) track(actionable.dataset.analytics, { tag: origin.localName });
});
Three rules generalise from this. Inside your own shadow tree, event.target is already precise — there is no retargeting within a tree, so closest() works normally and composedPath() is unnecessary. Across a boundary, membership is composedPath().includes(element) — never contains(), never closest(). And the path is the tree for delegation purposes: finding an ancestor means searching the path array, because closest() stops at the first boundary it meets.
The capture: true on the document listener is deliberate. A capture-phase listener runs before any handler inside the component can call stopPropagation(), so a well-behaved dismisser still sees clicks that a component chose to stop — and because membership is tested from the path, stopping propagation never causes a false dismissal.
Verification
const menu = document.querySelector('dropdown-menu');
const inner = menu.shadowRoot.querySelector('[data-action="delete"]');
// 1. Retargeting is real: the document sees the host, not the button.
document.addEventListener('click', (event) => {
console.assert(event.target === menu, 'target retargeted to the host');
console.assert(event.composedPath()[0] === inner, 'path[0] is the true origin');
console.assert(event.composedPath().includes(menu), 'the host is on the path');
// The tree-based tests that fail:
console.assert(menu.contains(inner) === false, 'contains() does not cross the boundary');
console.assert(inner.closest('dropdown-menu') === null, 'closest() stops at the root');
}, { once: true });
inner.click();
// 2. An outside click really is outside.
document.addEventListener('click', (event) => {
console.assert(!event.composedPath().includes(menu), 'outside click excludes the menu');
}, { once: true });
document.body.click();
// 3. The path is empty after dispatch finishes.
let captured = null;
document.addEventListener('click', (event) => { captured = event; }, { once: true });
inner.click();
setTimeout(() => {
console.assert(captured.composedPath().length === 0, 'path is only valid during dispatch');
}, 0);
Assertion three is the one worth adding to a test suite, because the failure it catches — code that stores the event and reads the path later — produces an empty array rather than an error, so the handler simply stops matching anything. In DevTools, logging event.composedPath() in a document-level listener and expanding the array shows the boundary crossings directly: an open root appears as a #shadow-root entry, and a closed one simply is not there.
When to Use vs When to Avoid
| Question | Use |
|---|---|
| Did this event originate inside my component? | event.composedPath().includes(this) |
| What was the true innermost element clicked? | event.composedPath()[0] |
| Find the nearest ancestor matching a selector, across boundaries | search composedPath() |
| Find the nearest ancestor within one tree | closest() — cheaper and sufficient |
| Handle a click inside my own shadow tree | event.target — no retargeting within a tree |
| Identify the element under the pointer for analytics | composedPath()[0], with the closed-root caveat |
| Reach inside a closed shadow root | Not possible — by design |
The last row is the honest limit. composedPath() reveals exactly as much as the caller could have discovered anyway; it is a convenience for open trees, not a hole in closed ones. Applications that need precise targeting inside third-party components need those components to be open, or to publish events of their own.
Frequently Asked Questions
Why does event.target point at the host instead of the button?
Because the standard retargets the reported target to the nearest ancestor in the listener’s own tree, so a listener never learns about nodes inside a shadow tree it cannot access. Use composedPath()[0] when you need the true origin.
Can I use contains() with composedPath()[0]?
No — that combines the fix with the original mistake. contains() walks a single tree, so it still returns false for a node in a shadow tree. Test membership with composedPath().includes(element).
Does composedPath() work after the event finishes?
No. It returns an empty array once dispatch is over, so anything derived from the path must be computed synchronously inside the handler rather than from a stored event object.
Why is my custom event not reaching a document listener at all?
Because CustomEvent is not composed by default. Without composed: true the event never crosses the shadow boundary, so retargeting is not the issue — the event simply stops at the shadow root.
Related
- Event Composition & Bubbling — the parent topic on propagation across boundaries.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Composing Custom Events Across Shadow Boundaries — the
composedflag this depends on. - Open vs Closed Shadow DOM Tradeoffs — what a closed root removes from the path.
- Avoiding Leaks from Event Listeners on window — scoping the document listener this pattern registers.