JavaScript Event Bubbling Explained for Beginners

Understand JavaScript event bubbling and capturing. Learn how events propagate through the DOM, how to stop bubbling, and when to use each phase with examples.

JavaScriptbeginner
10 min read

When you click a button inside a div inside the body, does the click event fire on just the button, or on the div and body too? The answer is all of them, because of event bubbling. Understanding how events propagate through the DOM tree is essential for building interactive web pages and is the foundation for event delegation. This guide explains the complete event flow with visual examples.

What is Event Bubbling?

Event bubbling is the process where an event starts at the target element (the one that was clicked) and then "bubbles up" through every ancestor element all the way to the document and window.

javascriptjavascript
// HTML: <div id="outer"><div id="inner"><button id="btn">Click</button></div></div>
 
document.getElementById("btn").addEventListener("click", () => {
  console.log("1. Button clicked");
});
 
document.getElementById("inner").addEventListener("click", () => {
  console.log("2. Inner div clicked");
});
 
document.getElementById("outer").addEventListener("click", () => {
  console.log("3. Outer div clicked");
});
 
document.body.addEventListener("click", () => {
  console.log("4. Body clicked");
});
 
// Clicking the button logs:
// 1. Button clicked
// 2. Inner div clicked
// 3. Outer div clicked
// 4. Body clicked

The event travels upward from the button through every parent element. This is bubbling.

The Three Phases of Event Propagation

Every DOM event goes through three phases:

javascriptjavascript
// Phase 1: CAPTURING (top โ†’ down)
// window โ†’ document โ†’ html โ†’ body โ†’ outer โ†’ inner โ†’ button
 
// Phase 2: TARGET
// The event reaches the target element (button)
 
// Phase 3: BUBBLING (bottom โ†’ up)
// button โ†’ inner โ†’ outer โ†’ body โ†’ html โ†’ document โ†’ window
PhaseDirectionDefault Listeners Fire?
CapturingWindow down to targetNo (unless capture: true)
TargetAt the clicked elementYes
BubblingTarget up to windowYes (default behavior)
javascriptjavascript
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");
const btn = document.getElementById("btn");
 
// Capture phase listener (fires during top-down phase)
outer.addEventListener("click", () => {
  console.log("Outer - CAPTURE phase");
}, { capture: true });
 
// Bubble phase listener (default, fires during bottom-up phase)
outer.addEventListener("click", () => {
  console.log("Outer - BUBBLE phase");
});
 
btn.addEventListener("click", () => {
  console.log("Button - TARGET phase");
});
 
// Clicking the button logs:
// Outer - CAPTURE phase   (top-down: outer reached first)
// Button - TARGET phase   (target reached)
// Outer - BUBBLE phase    (bottom-up: outer reached again)

event.target vs event.currentTarget

These two properties answer different questions during bubbling:

javascriptjavascript
document.getElementById("outer").addEventListener("click", (event) => {
  console.log("target:", event.target.id);
  // "target" is the element that was ACTUALLY clicked (the origin)
 
  console.log("currentTarget:", event.currentTarget.id);
  // "currentTarget" is the element the LISTENER is attached to
});
 
// If you click the button inside outer:
// target: "btn"          (you clicked the button)
// currentTarget: "outer"  (the listener is on outer)
PropertyMeaningChanges During Bubbling?
event.targetThe element that was originally clickedNo, always the same
event.currentTargetThe element whose listener is currently runningYes, changes at each level
javascriptjavascript
// Practical example: identify the clicked child
document.getElementById("nav").addEventListener("click", (e) => {
  // e.target = the specific link or span that was clicked
  // e.currentTarget = the nav element (where the listener lives)
  
  if (e.target.tagName === "A") {
    console.log("Link clicked:", e.target.href);
  }
});

Stopping Event Bubbling

stopPropagation()

The stopPropagation() method prevents the event from continuing to bubble up:

javascriptjavascript
document.getElementById("btn").addEventListener("click", (e) => {
  e.stopPropagation();
  console.log("Button clicked - event stops here");
});
 
document.getElementById("outer").addEventListener("click", () => {
  console.log("This will NOT fire when button is clicked");
});

stopImmediatePropagation()

This stops bubbling AND prevents other listeners on the same element from firing:

javascriptjavascript
const btn = document.getElementById("btn");
 
btn.addEventListener("click", (e) => {
  console.log("First handler runs");
  e.stopImmediatePropagation();
});
 
btn.addEventListener("click", () => {
  console.log("Second handler does NOT run");
});
 
document.body.addEventListener("click", () => {
  console.log("Body handler does NOT run either");
});

stopPropagation vs stopImmediatePropagation

MethodStops BubblingStops SiblingsUse Case
stopPropagation()YesNoPrevent parent handlers
stopImmediatePropagation()YesYesPrevent ALL other handlers

When Bubbling is Useful

Pattern 1: Closing Modals by Clicking Outside

javascriptjavascript
const modal = document.getElementById("modal");
const overlay = document.getElementById("overlay");
 
overlay.addEventListener("click", (e) => {
  // Only close if the click was on the overlay itself,
  // not on the modal content inside it
  if (e.target === overlay) {
    closeModal();
  }
});
 
// Alternative: stop propagation from the modal
modal.addEventListener("click", (e) => {
  e.stopPropagation(); // Clicks inside modal don't reach overlay
});
 
overlay.addEventListener("click", () => {
  closeModal(); // Only fires for clicks outside the modal
});

Pattern 2: Event Delegation on Lists

javascriptjavascript
// Instead of adding a listener to every <li>:
const list = document.getElementById("todo-list");
 
list.addEventListener("click", (e) => {
  const item = e.target.closest("li");
  if (item) {
    item.classList.toggle("completed");
  }
});
// Works for existing AND future list items!

Pattern 3: Dropdown Menus

javascriptjavascript
const dropdown = document.getElementById("dropdown");
const trigger = document.getElementById("dropdown-trigger");
 
trigger.addEventListener("click", (e) => {
  e.stopPropagation();
  dropdown.classList.toggle("open");
});
 
// Close dropdown when clicking anywhere else
document.addEventListener("click", () => {
  dropdown.classList.remove("open");
});
 
// Prevent clicks inside dropdown from closing it
dropdown.addEventListener("click", (e) => {
  e.stopPropagation();
});

The Capture Phase

By default, listeners fire during the bubbling phase. Set capture: true to listen during the capturing (top-down) phase instead:

javascriptjavascript
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");
const btn = document.getElementById("btn");
 
// Capture listener (fires BEFORE the target)
outer.addEventListener("click", () => {
  console.log("1. Outer CAPTURE");
}, { capture: true });
 
inner.addEventListener("click", () => {
  console.log("2. Inner CAPTURE");
}, { capture: true });
 
btn.addEventListener("click", () => {
  console.log("3. Button TARGET");
});
 
inner.addEventListener("click", () => {
  console.log("4. Inner BUBBLE");
});
 
outer.addEventListener("click", () => {
  console.log("5. Outer BUBBLE");
});
 
// Clicking the button logs in this exact order:
// 1. Outer CAPTURE
// 2. Inner CAPTURE
// 3. Button TARGET
// 4. Inner BUBBLE
// 5. Outer BUBBLE

When to Use Capture Phase

javascriptjavascript
// Use case: intercept events BEFORE they reach the target
 
// Global click logger (fires before any element handler)
document.addEventListener("click", (e) => {
  console.log("Click detected on:", e.target.tagName);
}, { capture: true });
 
// Focus management (capture is required for focus/blur)
document.addEventListener("focus", (e) => {
  console.log("Element focused:", e.target.id);
}, { capture: true }); // focus does not bubble, so capture is needed!

Events That Do NOT Bubble

Some events do not bubble by default:

EventBubbles?Alternative
focusNoUse focusin (bubbles)
blurNoUse focusout (bubbles)
mouseenterNoUse mouseover (bubbles)
mouseleaveNoUse mouseout (bubbles)
loadNoUse capture phase
scrollNo (on elements)Use capture phase
javascriptjavascript
// focus does NOT bubble
parent.addEventListener("focus", handler); // Won't fire for child inputs!
 
// focusin DOES bubble
parent.addEventListener("focusin", (e) => {
  console.log("Focus entered:", e.target.id); // Works!
});

Common Mistakes to Avoid

Mistake 1: Over-Using stopPropagation

javascriptjavascript
// PROBLEM: Stopping propagation breaks other features
button.addEventListener("click", (e) => {
  e.stopPropagation(); // This breaks:
  // - Analytics click tracking on document
  // - Close-on-click-outside for dropdowns
  // - Any parent delegation listeners
});
 
// BETTER: Check the target in the parent handler
document.addEventListener("click", (e) => {
  if (!e.target.closest(".dropdown")) {
    closeDropdown(); // Only close if click is outside
  }
});

Mistake 2: Confusing target and currentTarget

javascriptjavascript
// WRONG: Using currentTarget to identify what was clicked
list.addEventListener("click", (e) => {
  console.log(e.currentTarget.id); // Always "list", the element with the listener!
});
 
// CORRECT: Use target to identify the clicked element
list.addEventListener("click", (e) => {
  console.log(e.target.id); // The actual element that was clicked
});

Real-World Example: Nested Accordion

javascriptjavascript
function createNestedAccordion(containerId, data) {
  const container = document.getElementById(containerId);
 
  function buildAccordion(items, level = 0) {
    const wrapper = document.createElement("div");
    wrapper.className = `accordion-level-${level}`;
 
    items.forEach(item => {
      const section = document.createElement("div");
      section.className = "accordion-section";
 
      const header = document.createElement("button");
      header.className = "accordion-header";
      header.textContent = item.title;
      header.dataset.level = level;
 
      const content = document.createElement("div");
      content.className = "accordion-content";
      content.style.display = "none";
 
      const text = document.createElement("p");
      text.textContent = item.content;
      content.appendChild(text);
 
      if (item.children && item.children.length > 0) {
        const nested = buildAccordion(item.children, level + 1);
        content.appendChild(nested);
      }
 
      section.append(header, content);
      wrapper.appendChild(section);
    });
 
    return wrapper;
  }
 
  const accordion = buildAccordion(data);
  container.appendChild(accordion);
 
  // Single delegated listener handles ALL accordion headers
  container.addEventListener("click", (e) => {
    const header = e.target.closest(".accordion-header");
    if (!header) return;
 
    // Stop bubbling so parent accordions don't toggle
    e.stopPropagation();
 
    const content = header.nextElementSibling;
    const isOpen = content.style.display !== "none";
    content.style.display = isOpen ? "none" : "block";
    header.classList.toggle("open", !isOpen);
  });
}
 
createNestedAccordion("faq-container", [
  {
    title: "Getting Started",
    content: "Welcome to our platform.",
    children: [
      { title: "Creating an Account", content: "Click the sign-up button.", children: [] },
      { title: "Choosing a Plan", content: "We offer free and pro plans.", children: [] }
    ]
  },
  {
    title: "Advanced Features",
    content: "Power user tools.",
    children: [
      { title: "API Access", content: "Use your API key to connect.", children: [] }
    ]
  }
]);
Rune AI

Rune AI

Key Insights

  • Bubbling direction: Events travel from the target element upward through every ancestor to the document
  • target vs currentTarget: event.target is what was clicked; event.currentTarget is where the listener is attached
  • Three phases: Capture (down), target, bubble (up); listeners default to the bubble phase
  • Stop sparingly: stopPropagation() breaks delegation and analytics; prefer checking event.target in parent handlers
  • Non-bubbling events: Use focusin/focusout instead of focus/blur, and mouseover/mouseout instead of mouseenter/mouseleave when you need bubbling
RunePowered by Rune AI

Frequently Asked Questions

What is event bubbling in simple terms?

Event bubbling means that when an event fires on an element, it also fires on every parent element above it in the DOM tree. If you click a button inside a div inside the body, the click event fires on the button first, then the div, then the body, then the document. This bottom-to-top propagation is called "bubbling" because the event rises like a bubble.

How do I stop an event from bubbling?

Call `event.stopPropagation()` inside your event handler. This prevents the event from continuing to parent elements. Use `event.stopImmediatePropagation()` to also prevent other listeners on the same element from firing. Use these methods sparingly because stopping propagation can break event delegation and other features that rely on bubbling.

What is the difference between bubbling and capturing?

Bubbling travels up (from the clicked element to the document), while capturing travels down (from the document to the clicked element). Capturing happens first. By default, event listeners fire during the bubbling phase. Add `{ capture: true }` as the third argument to `addEventListener` to listen during the capturing phase instead.

Why would I use the capture phase?

Use the capture phase when you need to intercept an event before it reaches the target element. Common use cases include global event logging, focus management (since `focus` does not bubble), and intercepting events before child handlers can stop propagation. Most applications rarely need capture-phase listeners.

Does every event bubble?

No. Events like `focus`, `blur`, `mouseenter`, `mouseleave`, `load`, and `scroll` (on elements) do not bubble. For `focus` and `blur`, use their bubbling alternatives `focusin` and `focusout`. For `mouseenter` and `mouseleave`, use `mouseover` and `mouseout`. For non-bubbling events, you can listen in the capture phase instead.

Conclusion

Event bubbling is the default propagation behavior where events travel from the target element up through every ancestor to the document. This bottom-up flow enables powerful patterns like event delegation, where a single listener on a parent handles events from all its children. The three phases (capture, target, bubble) give you full control over when your handlers run. Use stopPropagation() sparingly and only when necessary, check event.target to identify the actual clicked element, and remember that some events like focus and mouseenter do not bubble. Understanding this flow is key to writing clean, efficient event handling code.