Skip to content

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.