Skip to content

Composed Events

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.

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 document
this._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 boundary
this._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.

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 composedPath
document.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 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.

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.

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.

OptionBenefitCost
composed: trueThe event escapes the shadow boundary, so outside code like analytics or a parent app can listen without knowing the component’s internal structureWidens 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 freelyOutside code, including tests that query from document, can never listen to it, even with bubbles: true set
  • Setting bubbles: true but forgetting composed: 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: true on 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 emit helper dispatches every custom event (like sl-show/sl-hide on sl-dialog) with composed: true by default, so an outside framework like React or Vue can attach a normal event listener without reaching into the shadow tree. (shoelace-style/shoelace)

What option must be set for a CustomEvent to cross the shadow DOM boundary?
By default, `bubbles: true` without `composed: true` — where does the event stop?
What does `event.composedPath()` return?
When you listen to a composed event from outside the shadow root, what does `event.target` point to?