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.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
composed: true | The event escapes the shadow boundary, so outside code like analytics or a parent app can listen without knowing the component’s internal structure | Widens your public event surface — renaming or re-timing the event later affects any outside consumer that has already attached a listener |
Leave composed unset (defaults to false) | The event stays an internal implementation detail you can change or remove freely | Outside code, including tests that query from document, can never listen to it, even with bubbles: true set |
Common mistakes
Section titled “Common mistakes”- Setting
bubbles: truebut forgettingcomposed: true— assuming the event escapes the shadow root because it bubbles, when it’s actually still stopped at the shadow boundary. Both flags are needed together for a listener outside the shadow tree to hear it. - Setting
composed: trueon every internal event indiscriminately — this leaks unnecessary implementation detail to the outside world. Reserve it for events that are genuinely part of the public API. - Holding onto the result of
composedPath()for later —composedPath()only returns a valid array while the event handler is actively running. Store a reference to use it in an async callback later and you’ll get an empty array.
💡 Real-world example
Shoelace / Web Awesome design system — its base component’s
emithelper dispatches every custom event (likesl-show/sl-hideonsl-dialog) withcomposed: trueby default, so an outside framework like React or Vue can attach a normal event listener without reaching into the shadow tree. (shoelace-style/shoelace)