Event Bubbling
This lesson explains how events bubble through the DOM, how bubbling behaves inside and outside Shadow DOM, and how to use bubbling safely in Web Components.
What Is Event Bubbling?
Event bubbling is the second phase of DOM event propagation. After
an event reaches its target element, it travels back up through
ancestor nodes unless something stops it. Parent elements can
listen for child events through this bubbling path.
In Web Components, bubbling still works inside a component, but
Shadow DOM introduces boundaries. Events do not automatically
bubble out of a shadow tree unless they are marked
composed: true.
| Phase | Direction |
| Capture | Window → target |
| Target | Event fires on the originating element |
| Bubble | Target → window |
Basic Bubbling Example
<article id="card">
<button type="button">Save</button>
</article>
document.getElementById("card")
.addEventListener("click", (event) => {
console.log("Card heard a click");
console.log(event.target.tagName); // BUTTON
});
The click starts on the button, then bubbles up to the
<article>. The parent listener runs even
though the user clicked a child element.
The Three Propagation Phases
parent.addEventListener("click", () => {
console.log("bubble phase");
});
parent.addEventListener("click", () => {
console.log("capture phase");
}, true);
| Phase | Listener Option | Order |
| Capture | addEventListener(type, fn, true) | Runs first, top-down |
| Target | Listener on the target element | Runs on the source node |
| Bubble | Default listener behavior | Runs last, bottom-up |
Bubbling in Custom Elements
class AppToolbar extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button data-action="save">Save</button>
<button data-action="cancel">Cancel</button>
`;
this.addEventListener("click", (event) => {
const action = event.target.dataset.action;
if (action) console.log(action);
});
}
}
A single listener on the host can handle clicks from multiple
child buttons. This delegation pattern works well for light DOM
custom elements without Shadow DOM.
Bubbling Inside Shadow DOM
class AppPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<div class="body">
<button>Action</button>
</div>
`;
}
connectedCallback() {
this.shadowRoot.addEventListener("click", (event) => {
console.log("Shadow listener:", event.target.tagName);
});
}
}
Inside the shadow tree, events bubble normally among internal
nodes. A click on the internal button can be handled by a
listener on the shadow root or host from within the component.
Shadow DOM Boundaries and Bubbling
By default, most events that bubble inside Shadow DOM do not
continue bubbling in the outer document. The shadow boundary acts
like a wall for standard bubbling behavior.
| Event | Bubbles Inside Shadow | Escapes to Light DOM |
Native click | Yes | Retargeted, not a full internal bubble path outside |
Custom event bubbles: false | Stays on target | No |
Custom event bubbles: true | Bubbles internally | Only if also composed: true |
To bubble a custom event out of Shadow DOM, you need both
bubbles: true and composed: true. See
the Composed Events
lesson next.
Bubbling Custom Events
this.dispatchEvent(
new CustomEvent("item-select", {
detail: { id: "item-42" },
bubbles: true
})
);
list.addEventListener("item-select", (event) => {
console.log(event.detail.id);
});
With bubbles: true, parent elements in the same DOM
tree can listen for the event without attaching a listener directly
to the child component.
Stopping Bubbling
child.addEventListener("click", (event) => {
event.stopPropagation();
});
parent.addEventListener("click", () => {
console.log("This will not run");
});
button.addEventListener("click", (event) => {
event.stopImmediatePropagation();
});
| Method | Effect |
stopPropagation() | Stops bubbling to ancestor nodes |
stopImmediatePropagation() | Also blocks other listeners on the same element |
preventDefault() | Blocks default browser action, not bubbling itself |
Event Delegation Pattern
menu.addEventListener("click", (event) => {
const item = event.target.closest("[data-menu-item]");
if (!item || !menu.contains(item)) return;
console.log(item.dataset.menuItem);
});
Event delegation attaches one listener to a parent and uses
bubbling to handle many child interactions. It reduces memory use
and works well for menus, lists, and tables in light DOM
components.
Delegation Limits with Shadow DOM
Delegation on a host element can handle events that reach the
host, but it cannot see arbitrary internal shadow nodes unless the
event crosses the boundary correctly.
- Delegate inside the shadow root for internal controls.
- Emit bubbling composed custom events for parent apps.
- Do not rely on outer-page delegation to inspect shadow internals.
Listening on the Host Element
class AppTabs extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<div role="tablist">
<button role="tab">One</button>
<button role="tab">Two</button>
</div>
`;
}
connectedCallback() {
this.shadowRoot.addEventListener("click", (event) => {
const tab = event.target.closest('[role="tab"]');
if (!tab) return;
this.activateTab(tab);
});
}
}
Internal delegation on shadowRoot is a common pattern
for encapsulated components that contain many interactive children.
Bubbling Through Nested Components
<app-layout>
<app-sidebar>
<app-menu-item>Settings</app-menu-item>
</app-sidebar>
</app-layout>
A bubbling custom event dispatched from
<app-menu-item> can be caught on
<app-sidebar> or
<app-layout> if it is composed and bubbles
through each shadow boundary along the way.
bubbles vs composed
| Option | Purpose |
bubbles: true | Event travels up ancestor nodes in the same tree |
composed: true | Event can cross Shadow DOM boundaries while bubbling |
| Both enabled | Parent apps and nested hosts can listen outside the shadow tree |
Full Bubbling Component Example
class TagList extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadowRoot.addEventListener("click", (event) => {
const removeBtn = event.target.closest("[data-remove]");
if (!removeBtn) return;
const id = removeBtn.dataset.remove;
this.dispatchEvent(
new CustomEvent("tag-remove", {
detail: { id },
bubbles: true,
composed: true
})
);
});
}
}
document.querySelector("tag-list")
.addEventListener("tag-remove", (event) => {
console.log("Removed:", event.detail.id);
});
When Bubbling Helps
- You want one parent listener for many child actions.
- You build menus, lists, tables, or toolbars.
- You need nested components to communicate upward.
- You prefer DOM events over callback props or framework wiring.
- You design a public component API with semantic custom events.
- You reduce the number of direct listeners in large UIs.
Event Bubbling Best Practices
- Use delegation inside shadow roots for internal controls.
- Set
bubbles: true on public custom events when parents should listen. - Add
composed: true when the event must leave Shadow DOM. - Stop propagation only when you have a clear reason.
- Use
closest() for robust delegation selectors. - Name events clearly and document their
detail payloads. - Test bubbling paths across nested custom elements.
Common Bubbling Mistakes
- Assuming native clicks fully bubble out of Shadow DOM.
- Forgetting
bubbles: true on custom events. - Using bubbling without
composed: true across shadow boundaries. - Calling
stopPropagation() too aggressively in shared components. - Confusing
preventDefault() with stopping bubbling. - Delegating on
document and expecting internal shadow targets. - Attaching duplicate listeners on both host and shadow root without need.
Key Takeaways
- Events bubble from target to ancestors after the target phase.
- Bubbling enables delegation and parent-level listeners.
- Shadow DOM limits default bubbling across boundaries.
- Custom events need
bubbles: true to travel upward. - Crossing shadow boundaries also requires
composed: true. - Good bubbling design keeps Web Component APIs simple and predictable.
Pro Tip
For Shadow DOM components, handle internal interaction with
delegation inside the shadow root, then re-dispatch a composed
bubbling custom event for parent applications. That gives you
both encapsulation and a clean public API.