Shadow DOM and Events
Learn how events behave inside Shadow DOM: bubbling, event retargeting, event.composedPath(), event.composed, and dispatching custom events.
A web component built with Shadow DOM keeps its internal structure hidden behind a shadow boundary. That encapsulation changes how events flow: some events cross the boundary and some don't, and the ones that do get retargeted so the outside world never sees your private internals. This chapter explains those rules so your components fire events the host page can actually use.
You'll cover four things: how events bubble across the shadow boundary, event retargeting, event.composedPath(), the event.composed flag, and dispatching custom events that escape the shadow tree.
This chapter assumes you already know the basics of Shadow DOM and general event bubbling and capturing. If custom events are new to you, read Dispatching Custom Events first.
Event Bubbling in Shadow DOM
Event bubbling describes how an event propagates up the DOM tree: it fires on the target element, then on each ancestor in turn, until it reaches document. (For the full picture see Bubbling and Capturing.)
Inside Shadow DOM the question becomes: does the event keep bubbling once it reaches the shadow root, out into the host's light DOM? That depends on whether the event is composed:
- Composed events cross the shadow boundary and continue bubbling into the light DOM. Most user-facing native events are composed:
click,mousedown,keydown,input,pointermove, and so on. - Non-composed events stop at the shadow root and never reach the host. Examples:
focus(usefocusin/focusoutif you need composed focus events),scroll,mouseenter, andload.
To stop an event from propagating at any point — whether or not it's composed — call event.stopPropagation().
Event retargeting
This is the part that surprises people. When a composed event crosses the boundary, the browser retargets it: to listeners in the light DOM, event.target points at the host element, not the inner element you actually clicked.
That's deliberate. Encapsulation would be pointless if outside code could read your component's private nodes off event.target. So the host page sees "something inside <my-widget> was clicked," not "the third <button> in your shadow tree was clicked." Inside the shadow tree, event.target still points at the real element.
If you need the real path through the shadow tree, use event.composedPath() — covered next.
Utilizing event.composedPath()
Because retargeting hides the inner element from event.target, you need another way to inspect the real propagation path. event.composedPath() returns an array of the nodes the event passed through, including nodes inside any shadow trees it crossed, ordered from the innermost target outward to window.
This is the reliable way to answer "which inner element was actually clicked?" from a light-DOM listener — but only for components whose shadow root is mode: 'open'. For a mode: 'closed' root, composedPath() stops at the host and the inner nodes are omitted, preserving the closed component's privacy.
Let's illustrate how event.composedPath() can be used to track event propagation within Shadow DOM:
<div id="outer"></div>
<script>
const outer = document.getElementById('outer');
const shadow = outer.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
inner.textContent = 'Click me';
inner.addEventListener('click', event => {
const composedInfo = document.createElement('p');
composedInfo.textContent = 'The event composedPath contains the following elements:';
shadow.appendChild(composedInfo);
const path = event.composedPath();
path.forEach((e) => {
const pathItem = document.createElement('p');
pathItem.textContent = e.tagName;
shadow.appendChild(pathItem);
});
});
shadow.appendChild(inner);
</script>Clicking the inner <div> lists every node in the composed path: it starts with the DIV you clicked, then DIV (the host #outer), then BODY, HTML, and finally entries for document and window (which print as undefined because they have no tagName). The first few entries are exactly what event.target hides from light-DOM listeners.
Understanding event.composed
The read-only event.composed property is a boolean: true if the event can cross shadow boundaries, false if it's confined to its shadow tree. You can't set it after the fact — for native events it's fixed by the spec, and for custom events you fix it when you construct the event via the composed option.
This flag matters most when you build a component and need to decide whether your custom events should escape. Native interaction events like click are composed by default; your own CustomEvents are not composed unless you opt in.
Let's examine how event.composed can be utilized in practice:
<div id="outer"></div>
<script>
const outer = document.getElementById('outer');
const shadow = outer.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', event => {
const composedInfo = document.createElement('p');
composedInfo.textContent = `Composed: ${event.composed}`;
shadow.appendChild(composedInfo);
});
shadow.appendChild(button);
</script>In this example, clicking the button within the shadow DOM triggers a click event. We dynamically create a <p> element to display the event.composed property within the shadow DOM.
Custom Events in Shadow DOM
Custom events let a component announce things to the outside world — "value changed," "item selected," "dialog closed" — without exposing its internals. This is the standard way a web component talks to the page that uses it. (See Dispatching Custom Events for the API in depth.)
For a custom event to reach a listener on the host element in the light DOM, you need two options set:
composed: true— lets the event cross the shadow boundary.bubbles: true— lets it travel up the tree to reach ancestor listeners.
Set only bubbles and the event bubbles inside the shadow tree but stops at the shadow root. Set only composed and it crosses the boundary but won't climb to ancestors. You almost always want both.
Let's create and dispatch a custom event within a shadow DOM:
<div id="container"></div>
<script>
const container = document.getElementById('container');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', () => {
const event = new CustomEvent('customEvent', { bubbles: true, composed: true });
button.dispatchEvent(event);
});
shadow.appendChild(button);
container.addEventListener('customEvent', () => {
const composedInfo = document.createElement('p');
composedInfo.textContent = `Custom Event Triggered!`;
container.appendChild(composedInfo);
});
</script>Clicking the button dispatches customEvent with both bubbles: true and composed: true, so it crosses the shadow boundary and bubbles up to the listener on the host (container) in the light DOM. To pass data along with the event, use the detail property:
button.dispatchEvent(new CustomEvent('customEvent', {
bubbles: true,
composed: true,
detail: { value: 42 }
}));
container.addEventListener('customEvent', (event) => {
console.log(event.detail.value); // 42
});Note that even though the event reaches the host, retargeting still applies: in the container listener, event.target is the host element, not the inner button. Use event.composedPath()[0] if you need the original target.
Quick reference
| Property / method | What it tells you |
|---|---|
event.composed | true if the event can cross shadow boundaries (read-only). |
event.composedPath() | Array of nodes the event traverses, including open shadow trees, innermost first. |
event.target (from light DOM) | The host element, due to retargeting — never the private inner node. |
bubbles option | Lets a custom event travel up the tree. |
composed option | Lets a custom event leave the shadow tree. |
Common gotchas
- Forgetting
composed: trueon custom events. Abubbles-only custom event silently dies at the shadow root and never reaches the host page — a frequent "my listener isn't firing" bug. - Reading
event.targetfrom outside. It's retargeted to the host. Reach forevent.composedPath()when you need the real inner target. focusdoesn't compose. Usefocusin/focusoutif you need focus changes to reach the host.closedshadow roots.composedPath()won't reveal nodes inside amode: 'closed'root, so don't rely on it to inspect closed components.
Related chapters
- JavaScript Shadow DOM — what the shadow tree is and how to attach one.
- Shadow DOM Slots and Composition — projecting light DOM into the shadow tree.
- Shadow DOM Styling — scoped styles inside a component.
- Custom Elements — defining your own HTML elements.
Conclusion
Events in Shadow DOM follow a few clear rules: composed events cross the boundary, non-composed ones don't, and composed events are retargeted to the host so your internals stay private. Use event.composed to check crossability, event.composedPath() to recover the real path, and CustomEvent with bubbles: true and composed: true to let your components talk to the page that hosts them.