Shadow DOM and events

A shadow tree exists to hide the guts of a component. The whole point is that whoever uses your <user-card> shouldn’t have to know it’s built out of a <div>, a <slot>, and three <button>s inside. That encapsulation would leak badly if events told the truth about where they came from.

Picture a click landing on a <button> deep inside the shadow DOM of <user-card>. Code in the main document listens for clicks, but it has no clue that button exists — maybe the component shipped from a third-party package. If the event reported its real target, every outside listener would suddenly be poking at internals it was never meant to see.

So the browser lies, on purpose. It retargets the event.

Here’s the smallest example that shows it:

<user-card></user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<p>
      <button>Click me</button>
    </p>`;
    // handler INSIDE the component
    this.shadowRoot.firstElementChild.onclick =
      e => alert("Inner target: " + e.target.tagName);
  }
});

// handler OUTSIDE the component
document.onclick =
  e => alert("Outer target: " + e.target.tagName);
</script>

Click the button and you get two alerts, in this order:

  1. Inner target: BUTTON — the handler that lives inside the shadow DOM sees the real element.
  2. Outer target: USER-CARD — the document handler sees the host, not the button.

Same physical click, two different values for event.target, depending on which side of the boundary you’re standing on.

document<user-card> (host)#shadow-root<button>e.target =BUTTONe.target = USER-CARD
One click, two truths. Inside the shadow tree target is the button; once the event crosses the boundary it's retargeted to the host.

Rather than trust the description, watch it happen. Click the button below: a handler inside the shadow tree logs first with the real <button> as its target, then the outside handler logs with the retargeted host. Same click, two truths.

interactiveOne click, two targets

Retargeting is a feature, not a quirk. The outer page gets a clean, stable story: “something was clicked on <user-card>.” It never has to learn the component’s private layout, and the component can rearrange its internals freely without breaking outside listeners.

Slotted elements are not retargeted

There’s an important exception. Retargeting happens for nodes that physically live inside the shadow tree. It does not happen for slotted elements, because those nodes really live in the light DOM — the ordinary page markup you wrote between the component’s tags. The shadow tree only borrows them for display.

<user-card id="userCard">
  <span slot="username">Maya Vance</span>
</user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div>
      <b>Name:</b> <slot name="username"></slot>
    </div>`;

    this.shadowRoot.firstElementChild.onclick =
      e => alert("Inner target: " + e.target.tagName);
  }
});

userCard.onclick = e => alert(`Outer target: ${e.target.tagName}`);
</script>

Click the text "Maya Vance" and both handlers — the one inside the shadow DOM and the one on the page — report the same target: SPAN. That <span slot="username"> belongs to the light DOM, so there’s nothing to hide and no retargeting.

Now click the <b>Name:</b> label instead. That element was authored inside shadowRoot.innerHTML, so it genuinely lives in the shadow tree. As the event bubbles out of the boundary, its event.target gets reset to <user-card>.

Light DOM — slotted
<span slot=“username”>
click → target stays
SPAN  (inner & outer)
Shadow DOM — internal
<b>Name:</b>
inner → B, outer →
USER-CARD  (retargeted)
Where a node physically lives decides whether it gets retargeted.

Click each part of the card below and compare what the inner and outer handlers report. The slotted name is light DOM, so its target survives the trip out; the Name: label was authored in the shadow tree, so it gets reset to the host.

interactiveSlotted vs. internal: who gets retargeted

Bubbling and event.composedPath()

For bubbling, the browser uses the flattened DOM — the tree you’d see after slots are filled with their assigned nodes. A slotted element sits visually inside its <slot>, so an event on it bubbles up to the <slot> and then onward through the shadow tree and out.

When you need the full, unretargeted route — every node the event actually passed through, shadow elements included — call event.composedPath(). The name is a hint: the path is computed against the composed (flattened) tree.

Take the slotted example above. Its flattened DOM looks like this:

<user-card id="userCard">
  #shadow-root
    <div>
      <b>Name:</b>
      <slot name="username">
        <span slot="username">Maya Vance</span>
      </slot>
    </div>
</user-card>

Click <span slot="username"> and event.composedPath() returns the whole chain from the target outward:

// event.composedPath() →
[span, slot, div, shadow-root, user-card, body, html, document, window]

That’s the exact parent sequence in the flattened tree. A normal handler outside the component still sees event.target === user-card, but composedPath() hands you the real trail if you need it.

spanslotdivshadowuser-cardbodyhtmldocumenttrue targetwhat outside handlers see as event.target
composedPath() walks the flattened tree from the true target all the way up to window.

Click the slotted name below. The outside handler still reports event.target as the host, but composedPath() reveals the full flattened trail — span, slot, the shadow root, the host, and up to window:

interactivecomposedPath() reveals the real trail

event.composed

Most events sail right through a shadow boundary. A few don’t. The deciding factor is the event’s read-only composed property.

  • composed: true — the event crosses shadow boundaries and can be heard outside the component.
  • composed: false — the event stays in the DOM tree where its target lives. Only handlers inside that same tree ever see it.

Don’t confuse composed with bubbles. They’re independent flags:

bubbles
travels up to ancestor elements
composed
passes through shadow-root boundaries
composed controls crossing shadow boundaries; bubbles controls climbing to ancestors. An event needs both to reach the outer document by bubbling.

Look at the UI Events specification and you’ll see that most events you interact with daily are composed: true:

  • blur, focus, focusin, focusout,
  • click, dblclick,
  • mousedown, mouseup, mousemove, mouseout, mouseover,
  • wheel,
  • beforeinput, input, keydown, keyup.

Every touch event and pointer event is composed: true as well.

A handful are composed: false:

  • mouseenter, mouseleave (these don’t bubble at all),
  • load, unload, abort, error,
  • select,
  • slotchange.

Those you can only catch on elements within the same DOM as the event’s target. If you attach an outside listener for slotchange, expecting it to fire when a component’s slotted content changes, it won’t reach you — that event never leaves the shadow tree.

Custom events

When you dispatch your own event with CustomEvent, it defaults to bubbles: false and composed: false. So to make an event bubble up and escape the component’s shadow DOM, you have to opt into both flags explicitly.

Here a div#inner lives in the shadow DOM of div#outer. Two test events fire on it — same everything except composed. Only the composed one reaches the document listener:

<div id="outer"></div>

<script>
outer.attachShadow({mode: 'open'});

let inner = document.createElement('div');
outer.shadowRoot.append(inner);

/*
div(id=outer)
  #shadow-dom
    div(id=inner)
*/

document.addEventListener('test', event => alert(event.detail));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: true,
  detail: "composed"
}));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: false,
  detail: "not composed"
}));
</script>

You get a single alert: "composed". The second dispatch bubbles fine, but only up to the shadow root — it can’t cross into the document, so no outside handler hears it.

document (listener here)div#outer (host)#shadow-root ← boundarydiv#innercomposed:true → outcomposed:falsestops at boundary
Both events bubble inside the shadow tree, but only composed:true crosses the boundary to reach the document.

Fire each event below and watch who hears it. Both events bubble, and a listener inside the shadow tree always catches them — but only composed: true crosses the boundary to reach the document listener outside:

interactivecomposed decides what escapes

Summary

  • Events inside a shadow tree are retargeted: caught from outside, their target is the host element. This keeps a component’s internals private.
  • Slotted nodes are not retargeted, because they physically belong to the light DOM. Only nodes authored inside the shadow tree get their target reset on the way out.
  • Bubbling uses the flattened DOM. event.composedPath() returns the full path through shadow elements (from the true target up to window) — but a closed tree hides that and starts the path at the host.
  • The composed flag decides whether an event crosses shadow boundaries. Most built-in events are composed: true, per their specs:
  • A few built-in events are composed: false, so they’re only catchable within the target’s own DOM:
    • mouseenter, mouseleave (which also don’t bubble),
    • load, unload, abort, error,
    • select,
    • slotchange.
  • For a CustomEvent, both flags default to false. Set composed: true (and bubbles: true) to make it leave the component. To scope an event to the immediate enclosing component, dispatch it on the host with composed: false.