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:
Inner target: BUTTON— the handler that lives inside the shadow DOM sees the real element.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.
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.
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>.
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.
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.
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:
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:
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.
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:
Summary
- Events inside a shadow tree are retargeted: caught from outside, their
targetis 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 towindow) — but aclosedtree hides that and starts the path at the host. - The
composedflag decides whether an event crosses shadow boundaries. Most built-in events arecomposed: true, per their specs:- UI Events https://www.w3.org/TR/uievents,
- Touch Events https://w3c.github.io/touch-events,
- Pointer Events https://www.w3.org/TR/pointerevents,
- and so on.
- 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 tofalse. Setcomposed: true(andbubbles: true) to make it leave the component. To scope an event to the immediate enclosing component, dispatch it on the host withcomposed: false.