Event Propagation

Events don't just fire on one element. They travel through the DOM.

Three Phases

Every DOM event goes through three phases.

Click on <button> inside <div> inside <body>:

1CAPTURING: document → html → body → div → button
2TARGET: event fires on button (the target)
3BUBBLING: button → div → body → html → document
// By default, addEventListener listens during BUBBLING:
div.addEventListener("click", handler); // phase 3

// To listen during CAPTURING:
div.addEventListener("click", handler, true); // phase 1
// or: { capture: true }

// Check which phase in the handler:
element.addEventListener("click", (e) => {
  e.eventPhase; // 1=capturing, 2=target, 3=bubbling
});

Bubbling

Events bubble UP from the target to the root.

// HTML:
// <div id="outer">
//   <div id="inner">
//     <button id="btn">Click me</button>
//   </div>
// </div>

outer.addEventListener("click", () => console.log("outer"));
inner.addEventListener("click", () => console.log("inner"));
btn.addEventListener("click", () => console.log("btn"));

// Click the button → output:
// "btn"    (target)
// "inner"  (bubbles up)
// "outer"  (bubbles up)

// The event "bubbles" from child to parent to grandparent...

💡 Bubbling is why event delegation works — a click on a child element also fires on all its ancestors.

Capturing

Events travel DOWN from root to target before bubbling.

// Capture phase (down) + bubble phase (up):
outer.addEventListener("click", () => console.log("outer capture"), true);
inner.addEventListener("click", () => console.log("inner capture"), true);
btn.addEventListener("click", () => console.log("btn"));
inner.addEventListener("click", () => console.log("inner bubble"));
outer.addEventListener("click", () => console.log("outer bubble"));

// Click button → output:
// "outer capture"  ↓ (capturing down)
// "inner capture"  ↓
// "btn"            ● (target)
// "inner bubble"   ↑ (bubbling up)
// "outer bubble"   ↑

Capturing is rarely used. The main use case is intercepting events before they reach the target — like a global click handler that blocks certain actions.

stopPropagation

Stop the event from traveling further.

// stopPropagation — prevent bubbling:
btn.addEventListener("click", (e) => {
  e.stopPropagation();
  console.log("btn clicked");
  // Parent handlers will NOT fire!
});

outer.addEventListener("click", () => {
  console.log("This won't run if btn is clicked");
});

// stopImmediatePropagation — also stops OTHER handlers
// on the SAME element:
btn.addEventListener("click", (e) => {
  e.stopImmediatePropagation();
  console.log("First handler");
});
btn.addEventListener("click", () => {
  console.log("This won't run either!");
});

stopPropagation

Stops bubbling/capturing

Other same-element handlers still fire

stopImmediatePropagation

Stops ALL propagation

Even same-element handlers stop

Practical Use

When propagation knowledge matters.

// 1. Modal close — click outside to close:
overlay.addEventListener("click", () => closeModal());
modal.addEventListener("click", (e) => {
  e.stopPropagation(); // don't close when clicking INSIDE modal
});

// 2. Dropdown menu — close on outside click:
document.addEventListener("click", (e) => {
  if (!dropdown.contains(e.target)) {
    dropdown.classList.add("hidden");
  }
});

// 3. Prevent link navigation in a card:
card.addEventListener("click", () => openCard());
card.querySelector("a").addEventListener("click", (e) => {
  e.stopPropagation(); // let link work normally
});

FAQ

Common questions about event propagation.