Composed Events
Shadow DOM creates an event boundary
Section titled “Shadow DOM creates an event boundary”Every shadow root is an event boundary. An event that originates inside a shadow root will not bubble past the shadow host by default — it is trapped inside the shadow tree. This is part of the encapsulation guarantee: the outside world cannot see what is happening inside your component unless you explicitly allow it.
To let an event cross that boundary you must set composed: true when dispatching it.
The composed flag
Section titled “The composed flag”Without composed — event stops at the shadow root
Section titled “Without composed — event stops at the shadow root”// Inside shadow root — this event will NOT reach the documentthis._shadow.querySelector('button').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('shadow-click', { bubbles: true, // composed is false by default — event stops at shadow boundary }));});Even though bubbles: true is set, the event will not leave the shadow tree. Any listener on document or a light-DOM ancestor will never see it.
With composed: true — event crosses the shadow boundary
Section titled “With composed: true — event crosses the shadow boundary”// Composed — this event WILL cross the shadow boundarythis._shadow.querySelector('button').addEventListener('click', () => { this.dispatchEvent(new CustomEvent('shadow-click', { bubbles: true, composed: true, // ← allows crossing the shadow boundary }));});Now the event bubbles normally through all light-DOM ancestors all the way to window.
event.composedPath()
Section titled “event.composedPath()”composedPath() returns the full array of nodes the event travels through, including nodes inside shadow roots. This is only useful while the event is being dispatched — once the event handler returns, the array is cleared.
// Reading composedPathdocument.addEventListener('shadow-click', (e) => { console.log(e.composedPath()); // includes shadow nodes console.log(e.target); // retargeted to the host element from outside});When you read e.target from outside the shadow tree you will see the shadow host, not the original element that fired the event. This is called target retargeting — it hides the internal structure of your component from outside listeners.
Target retargeting
Section titled “Target retargeting”Target retargeting is a deliberate privacy mechanism. Imagine a complex component with many internal buttons. Outside code should not be able to tell which internal node was clicked — only that the component itself was interacted with. The browser automatically rewrites event.target to the host element when the event is observed from outside the shadow tree.
Inside the shadow tree the original target is still visible. From outside, event.target always points to the shadow host.
When to use composed: true
Section titled “When to use composed: true”A good rule of thumb: use composed: true for events that represent user-facing actions your component publishes as part of its public API (for example, value-change, submit, close). Keep lower-level internal events without composed so they stay private to the component.
Live demo
Section titled “Live demo”The purple button dispatches a composed-click event with composed: true. The document listener fires and confirms the event arrived, showing that event.target has been retargeted to the <composed-demo> host element.
The grey button dispatches a not-composed-click event without composed: true. The document listener for that event name is registered — but it never fires. The message you see is set directly on the host element’s own listener, proving the event was stopped at the shadow boundary.