Shadow DOM naturally contains events inside a component's own tree. This lesson explains the composed option that lets events cross that boundary, and how event retargeting works as a result.
Why Events Need composed: true to Escape Shadow DOM
By design, Shadow DOM encapsulates not just markup and styles but also event propagation: an event dispatched inside a shadow tree normally stops bubbling at that shadow root's boundary, so a parent document never even knows it happened. This is intentional encapsulation, protecting a component's internal structure from leaking event details to the outside.
Setting composed: true on a CustomEvent explicitly opts back into crossing that boundary, letting the event continue bubbling up through the light DOM ancestors above the component, exactly as if Shadow DOM weren't there at all.
// Inside a component with Shadow DOM:
this.dispatchEvent(new CustomEvent('selection-changed', {
detail: { id: 42 },
bubbles: true, // bubble within/beyond this component
composed: true, // ALSO cross the shadow boundary into light DOM
}));
Without composed: true, this event would never be visible to a listener attached outside this component's own shadow tree, even with bubbles: true set.
bubbles vs composed
bubbles: true -> travels up through ancestor elements (within a tree)
composed: true -> ALSO crosses shadow DOM boundaries between trees
// Native events like "click" already have both set to true by the browser.
// CustomEvents you create must opt into both explicitly if needed.
Native browser events (click, input, focus... mostly) already have composed: true set by the platform.
Custom events you create with new CustomEvent() default to composed: false — you must opt in explicitly.
Use composed: true for any event meant to be heard outside this component, e.g. by its parent's parent.
Leave composed: false for events that are only meant to be handled by this component's own immediate light-DOM listeners, if any.
Composed Events Cheat Sheet
Deciding on bubbles/composed for a given event.
Event Purpose
bubbles
composed
Parent component needs to hear it
true
true
Grandparent or page-level code needs to hear it
true
true
Purely internal signal, never meant to leave this component
false
false
Mimicking a native event's behavior closely
true
true
Event Retargeting Across Shadow Boundaries
When a composed event crosses a shadow boundary, the browser 'retargets' it for listeners outside that boundary: event.target appears to be the shadow host element itself (your custom element), not the specific internal element that originally dispatched it — protecting the component's internal structure from being exposed to outside code.
// Inside <my-widget>'s shadow root, an inner <button> dispatches an event.
// A listener OUTSIDE the shadow root sees:
event.target === document.querySelector('my-widget'); // retargeted to the host
// A listener INSIDE the shadow root would see the actual inner <button> instead.
This is exactly why event.detail (which you control) is the reliable way to pass information, rather than inspecting event.target's internal structure.
When Not to Use composed: true
Not every internal event needs to escape the shadow boundary. If an event is purely a private implementation detail — coordinating between two internal sub-parts of the same component — leaving composed: false keeps your Shadow DOM encapsulation intact and avoids leaking implementation noise to ancestors that shouldn't care.
Common Mistakes
Assuming all events automatically cross Shadow DOM boundaries, then being confused when an ancestor's listener never fires.
Setting composed: true without also setting bubbles: true, which is rarely useful — the event needs to bubble at all before composition even matters.
Relying on event.target's internal structure across a shadow boundary instead of using event.detail for data.
Marking every internal, implementation-detail event as composed: true out of habit, unnecessarily exposing internal coordination events to ancestors.
Key Takeaways
Shadow DOM stops event bubbling at its boundary by default, as part of its encapsulation guarantees.
composed: true explicitly opts a custom event into crossing that shadow boundary.
Composed events crossing a boundary get their event.target retargeted to the shadow host, not the internal element.
Use event.detail, not event.target's internal structure, to pass data across a shadow boundary reliably.
Pro Tip
When designing a component's public event API, default new events to { bubbles: true, composed: true } unless you have a specific reason for an event to stay purely internal — most component-to-application communication needs both.
You now understand composed events and Shadow DOM boundaries. Next, look at additional event listener options Lit supports.