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.
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.
// 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 clickedThe 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:
// 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| Phase | Direction | Default Listeners Fire? |
|---|---|---|
| Capturing | Window down to target | No (unless capture: true) |
| Target | At the clicked element | Yes |
| Bubbling | Target up to window | Yes (default behavior) |
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:
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)| Property | Meaning | Changes During Bubbling? |
|---|---|---|
event.target | The element that was originally clicked | No, always the same |
event.currentTarget | The element whose listener is currently running | Yes, changes at each level |
// 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:
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:
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
| Method | Stops Bubbling | Stops Siblings | Use Case |
|---|---|---|---|
stopPropagation() | Yes | No | Prevent parent handlers |
stopImmediatePropagation() | Yes | Yes | Prevent ALL other handlers |
When Bubbling is Useful
Pattern 1: Closing Modals by Clicking Outside
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
// 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
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:
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 BUBBLEWhen to Use Capture Phase
// 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:
| Event | Bubbles? | Alternative |
|---|---|---|
focus | No | Use focusin (bubbles) |
blur | No | Use focusout (bubbles) |
mouseenter | No | Use mouseover (bubbles) |
mouseleave | No | Use mouseout (bubbles) |
load | No | Use capture phase |
scroll | No (on elements) | Use capture phase |
// 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
// 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
// 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
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
Key Insights
- Bubbling direction: Events travel from the target element upward through every ancestor to the document
- target vs currentTarget:
event.targetis what was clicked;event.currentTargetis 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 checkingevent.targetin parent handlers - Non-bubbling events: Use
focusin/focusoutinstead offocus/blur, andmouseover/mouseoutinstead ofmouseenter/mouseleavewhen you need bubbling
Frequently Asked Questions
What is event bubbling in simple terms?
How do I stop an event from bubbling?
What is the difference between bubbling and capturing?
Why would I use the capture phase?
Does every event bubble?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.