skip to content

A click on a <button> inside a component's open shadow root is handled by a listener on the shadow host, but event.target is the host element rather than the button. Why does the browser report it that way, and how do you find the element that was really clicked?

level: middleimportance: must knowfreq 54%

answer

  1. target is adjusted per listener
  2. nearest ancestor in the listener's tree
  3. currentTarget is unaffected
  4. the path knows what target forgot
  5. closed roots truncate the path

basics

~20 s

The browser retargets an event's target as it crosses a shadow boundary, rewriting it to the nearest ancestor in the same tree as the listening node, so encapsulation holds. Use event.composedPath()[0] to reach the real innermost element.

solid answer

~50 s

That is **retargeting**, and it is deliberate. As a composed event propagates out of a shadow tree, the browser rewrites `event.target` for each listener to the closest ancestor that lives in the *same* tree as the node the listener is attached to. A listener on the host is in the outer document tree, so the deepest node it is allowed to know about is the host itself — otherwise every outside listener would learn the component's internal structure and start depending on it. A listener attached inside the shadow root sees the real `<button>`, because it is in the same tree. To get the actual element from outside, call `event.composedPath()`, which returns the full propagation path innermost-first: `composedPath()[0]` is the button. That works for an open root; across a closed root the path is truncated at the host. `relatedTarget` on events like `mouseover` is retargeted by the same rule.

code

javascript · 15 lines
javascript
const card = document.createElement('div');
document.body.append(card);
const root = card.attachShadow({ mode: 'open' });
root.innerHTML = '<button id="go">Go</button>';

root.getElementById('go').addEventListener('click', (e) => {
  console.log('inside :', e.target.id);            // "go" - same tree
});

card.addEventListener('click', (e) => {
  console.log('outside:', e.target === card);      // true - retargeted to host
  console.log('real   :', e.composedPath()[0].id); // "go"
});

root.getElementById('go').click();

go deeper

for a junior

Know the symptom and the fix: outside a shadow root event.target is the host, and event.composedPath()[0] gives you the element that was really clicked.

for a middle

State the rule precisely — target becomes the nearest ancestor in the same tree as the listening node — and explain that it exists so outside code cannot depend on a component's internal structure.

for a senior

Diagnose from symptoms. Separate a retargeted target from a non-composed event that never escaped, and know the parallel retargeting of relatedTarget, document.activeElement and elementFromPoint.

for a principal

Own the consequence for architecture: global delegated listeners and outside-click utilities stop working once components ship shadow roots, so decide early whether the app standardises on composedPath-aware helpers or on per-component event APIs.

## The rule When an event propagates across a shadow boundary, the DOM standard adjusts the value of `event.target` per listener. The rule is: for a listener on node *N*, `event.target` is the closest inclusive ancestor of the original target that lives in the same node tree as *N*. Nothing is lost — the event object is the same object throughout, and `target` is computed from the propagation path when each listener runs. So for a page like this: ```html <div id="wrapper"> <my-card></my-card> <!-- shadow root contains: <div class="body"><button>Go</button></div> --> </div> ``` a click on the button gives: | listener on | event.target | event.currentTarget | |---|---|---| | the `<button>` (inside the root) | `<button>` | `<button>` | | `.body` (inside the root) | `<button>` | `.body` | | `<my-card>` (the host, outer tree) | `<my-card>` | `<my-card>` | | `#wrapper` | `<my-card>` | `#wrapper` | | `document` | `<my-card>` | `document` | Note how `currentTarget` behaves normally — it is always the node whose listener is running. Only `target` is rewritten, and only when the boundary is crossed. ## Why the platform does this Retargeting is what makes shadow DOM an encapsulation feature rather than a styling trick. Without it, a page-level analytics handler, a global click-outside handler, or a framework's delegated listener would receive internal component nodes as targets. Code would then be written against those nodes — `if (e.target.classList.contains('body'))` — and the component author could never rename or restructure anything again. Retargeting means the outside world's mental model matches the component's public shape: something happened *in* `<my-card>`. The same adjustment applies to `event.relatedTarget` on `mouseover`/`mouseout`/`focusin`-style events, so a hover leaving an internal node for another internal node can appear to an outside listener as though both sides are the host. That is the usual reason a naive "did the pointer leave my component?" check misbehaves around web components: outside the boundary, `target` and `relatedTarget` can both be the host, so a comparison that expects them to differ sees no movement at all. ## Getting the real element: composedPath() `Event.composedPath()` returns the array of objects the event will visit, ordered innermost first — original target, its ancestors, across boundaries, up to `window`. From an outside listener: ```js card.addEventListener('click', (e) => { console.log(e.target); // <my-card> (retargeted) console.log(e.composedPath()[0]); // <button> (the real one, open root) }); ``` Two caveats matter in interviews. First, `composedPath()` respects closed roots: for an event that crossed a `mode: 'closed'` boundary, the returned path is truncated so an outside listener sees only the host and above. Second, `composedPath()` is meaningful **while the event is being dispatched**; after dispatch finishes it returns an empty array, so caching the event and reading the path later gives nothing. A closely related tool is `Node.getRootNode()`. Called on a node inside a shadow tree it returns that `ShadowRoot`; called with `{ composed: true }` it walks all the way out to the `Document`. That is how a piece of code decides whether it is running inside a shadow tree at all, and it pairs naturally with `root.host` to climb boundaries deliberately. ## Where retargeting bites in practice - **Click-outside logic.** A dropdown that closes when a `document` click's target is not inside its element will misfire in both directions once shadow DOM is involved. `event.composedPath().includes(myElement)` is the shadow-aware check, because the path contains the nodes on both sides of the boundary. - **Event delegation from the document.** A delegated handler matching `e.target.closest('.row')` never sees rows that live inside a shadow root; it sees the host. Delegate *inside* the root instead, or match against `composedPath()`. - **Focus.** `document.activeElement` is retargeted the same way — it reports the host when focus is on an inner node. `shadowRoot.activeElement` gives the real one, and chaining through nested roots is sometimes required. - **Hit testing.** `document.elementFromPoint(x, y)` also reports the host for a point over a shadow-internal node; `shadowRoot.elementFromPoint(x, y)` resolves within that root. ## Not every event escapes Retargeting only matters for events that cross the boundary at all. An event crosses only when it was dispatched with `composed: true`. Most user-interaction events are — `click`, `pointerdown`, `keydown`, `input`, `focusin` — while several others, notably `change`, are not composed and simply stop at the shadow root. If an outside listener sees *nothing*, the question is composition; if it sees the host instead of the inner node, the question is retargeting. Keeping those two failure modes apart is the mark of someone who has actually debugged this.

  • Why does document.activeElement return the custom element rather than the focused input inside its shadow root?
    `activeElement` is retargeted by the same rule: from the document's tree, the deepest node it may report is the host. Read `host.shadowRoot.activeElement` to get the focused node inside an open root, repeating the step for nested roots. `document.activeElement` staying on the host is the expected result, not a bug.
  • An outside listener sees no event at all from inside a shadow root. What is the likely cause?
    The event was dispatched with `composed: false`, so it stops at the shadow root instead of being retargeted. Several native events behave this way — `change` is the classic one — and a `CustomEvent` is `composed: false` unless the dispatching code says otherwise. Retargeting explains a wrong target; composition explains a missing event.
  • You have a node and want to know which tree it lives in. What do you call?
    `node.getRootNode()` returns the `ShadowRoot` if the node is inside a shadow tree, or the `Document` otherwise; `node.getRootNode({ composed: true })` walks all the way out to the document across every boundary. From a `ShadowRoot` you can then step to `root.host` to continue climbing deliberately.
  • Does composedPath() work after the event has finished dispatching?
    No. The path is derived from the in-flight dispatch, so once dispatch completes `composedPath()` returns an empty array. If you need the inner element later, read `composedPath()[0]` inside the handler and store that node rather than storing the event object.

saying these in an interview costs you the question

  • Thinks retargeting is a browser bug or a framework quirk
  • Says currentTarget is rewritten along with target
  • Uses element.contains(e.target) for outside-click checks across shadow DOM
  • Assumes composedPath still works after the handler returns
  • Confuses a non-composed event with a retargeted one

context