JavaScript Event Propagation: Capturing, Target and Bubbling
Understand how events travel through the DOM in three phases, see the order of listeners, and learn when to use stopPropagation and stopImmediatePropagation.
What you will learn
In the last lesson you added a listener to a button. But a button never lives alone. It sits inside a card, which sits inside a section, which sits inside the body. So what happens when you click that button? Did you click the button only, or also the card, or the whole page?
The answer is: all of them. The event travels through the DOM tree. This journey is called event propagation. In this lesson you will learn its three phases (capturing, target and bubbling), how to see the exact order, and how to use stopPropagation and stopImmediatePropagation safely.
The problem: one click, many elements
Look at three boxes, one inside the other, with a button in the middle:
<div id="outer" class="box">Outer
<div id="middle" class="box">Middle
<div id="inner" class="box">Inner
<button id="btn">Click me</button>
</div>
</div>
</div>
.box {
padding: 14px;
margin: 8px 0;
border: 2px solid #555;
border-radius: 8px;
}
#outer { background: #fee2e2; }
#middle { background: #fef3c7; }
#inner { background: #dcfce7; }
Now we add a click listener to every element, and then click only the button:
const outer = document.getElementById("outer");
const middle = document.getElementById("middle");
const inner = document.getElementById("inner");
const btn = document.getElementById("btn");
for (const el of [outer, middle, inner, btn]) {
el.addEventListener("click", function () {
console.log("Clicked:", this.id);
});
}
// You click the button. The Console shows:
// Clicked: btn
// Clicked: inner
// Clicked: middle
// Clicked: outer
You clicked one button, but four listeners ran, from the inside out. That is bubbling, and it is the default behaviour of most events.
The three phases
Every event goes on a journey with three phases. Imagine dropping a stone into water.
window
|
document
|
<html>
|
<body>
|
#outer 1. CAPTURING (going down: window -> ... -> parent of target)
|
#middle
|
#inner
|
#btn 2. TARGET (the element that was really clicked)
|
#inner
|
#middle 3. BUBBLING (going up: parent of target -> ... -> window)
|
#outer
|
<body> ... document ... window
- Capturing phase. The event starts at the very top (
window) and travels down toward the element you clicked. - Target phase. The event reaches the element that was actually clicked. This element is called the target.
- Bubbling phase. The event travels back up to the top, like a bubble rising in water.
The word to remember: the event does not just happen at the element. It passes through every ancestor twice, once going down and once coming up.
Which phase does my listener use?
By default, addEventListener listens during the bubbling phase. To listen during the capturing phase, pass true (or { capture: true }) as the third argument.
// Bubbling (default)
element.addEventListener("click", handler);
element.addEventListener("click", handler, false);
// Capturing
element.addEventListener("click", handler, true);
element.addEventListener("click", handler, { capture: true });
Seeing the real order
Let us add both a capturing and a bubbling listener to the three boxes, and one normal listener to the button. Then click the button.
const outer = document.getElementById("outer");
const middle = document.getElementById("middle");
const inner = document.getElementById("inner");
const btn = document.getElementById("btn");
for (const el of [outer, middle, inner]) {
el.addEventListener("click", () => console.log("capturing ->", el.id), true);
el.addEventListener("click", () => console.log("bubbling ->", el.id));
}
btn.addEventListener("click", () => console.log("TARGET ->", btn.id));
// Click the button. The Console shows:
// capturing -> outer
// capturing -> middle
// capturing -> inner
// TARGET -> btn
// bubbling -> inner
// bubbling -> middle
// bubbling -> outer
Read it from top to bottom: the event goes down (capturing), touches the target, then comes up (bubbling).
Remember: Most of the time you only use the bubbling phase, which is the default. Capturing is rare. It is useful when a parent must see an event before its children, for example a global “click anywhere” logger, or to catch events that do not bubble (explained below).
target vs currentTarget
Two properties on the event object are easy to confuse. Both are important.
| Property | Meaning |
|---|---|
event.target |
The element that was actually clicked (where the event started). It never changes during the journey. |
event.currentTarget |
The element that owns the listener that is running right now. It changes as the event moves. |
const outer = document.getElementById("outer");
outer.addEventListener("click", function (event) {
console.log("target: ", event.target.id);
console.log("currentTarget:", event.currentTarget.id);
});
// Click the button. target: btn currentTarget: outer
// Click the Inner box. target: inner currentTarget: outer
// Click the Outer box. target: outer currentTarget: outer
The listener is on outer, so currentTarget is always outer. But target tells you what the user really touched. This one idea powers event delegation, the next lesson.
eventPhase
You can ask the event which phase it is in. 1 is capturing, 2 is target, 3 is bubbling:
const outer = document.getElementById("outer");
outer.addEventListener("click", (e) => console.log("capture phase:", e.eventPhase), true);
outer.addEventListener("click", (e) => console.log("bubble phase: ", e.eventPhase));
// Click the button: capture phase: 1 bubble phase: 3
// Click the Outer box itself (it is now the target): both print 2
stopPropagation: stop the journey
Sometimes you do not want the event to keep travelling. event.stopPropagation() stops it from going to the next element on its path.
const outer = document.getElementById("outer");
const middle = document.getElementById("middle");
const inner = document.getElementById("inner");
const btn = document.getElementById("btn");
outer.addEventListener("click", () => console.log("outer"));
middle.addEventListener("click", () => console.log("middle"));
inner.addEventListener("click", (event) => {
console.log("inner: stopping here");
event.stopPropagation();
});
btn.addEventListener("click", () => console.log("btn"));
// Click the button:
// btn
// inner: stopping here
// (middle and outer never run)
The event went from the button up to inner, and inner said “stop”. middle and outer never heard about the click.
Note that stopPropagation does not stop other listeners on the same element. If inner has a second click listener, it still runs. For that, you need the next method.
stopImmediatePropagation: stop everything, even on the same element
event.stopImmediatePropagation() does two things:
- It stops the event from reaching other elements (like
stopPropagation). - It also stops the remaining listeners on the same element.
const btn = document.getElementById("btn");
btn.addEventListener("click", () => console.log("listener 1"));
btn.addEventListener("click", (event) => {
console.log("listener 2: stopping everything");
event.stopImmediatePropagation();
});
btn.addEventListener("click", () => console.log("listener 3")); // never runs
// Click the button:
// listener 1
// listener 2: stopping everything
Compare the two methods:
| Method | Other elements on the path | Other listeners on the same element |
|---|---|---|
stopPropagation() |
Stopped | Still run |
stopImmediatePropagation() |
Stopped | Stopped |
Listeners run in the order they were added, so listener 1 ran before the stop. Only the ones after are cancelled.
Real use case: close a modal when the overlay is clicked
Most websites close a popup when you click the dark area outside it. The structure is an overlay that contains the modal:
<button id="open-btn">Open modal</button>
<div id="overlay" hidden>
<div id="modal">
<p>Click outside the white box to close me.</p>
<button id="close-btn">Close</button>
</div>
</div>
#overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
#overlay[hidden] {
display: none;
}
#modal {
background: white;
padding: 20px;
border-radius: 10px;
}
The wrong way
If you just close on any overlay click, clicking inside the modal also closes it, because the click bubbles from the modal up to the overlay:
const overlay = document.getElementById("overlay");
const openBtn = document.getElementById("open-btn");
openBtn.addEventListener("click", () => (overlay.hidden = false));
// Bug: clicking inside the white box bubbles up and closes the modal too
overlay.addEventListener("click", () => (overlay.hidden = true));
The fix with stopPropagation
You could stop the click inside the modal from bubbling:
const overlay = document.getElementById("overlay");
const modal = document.getElementById("modal");
const openBtn = document.getElementById("open-btn");
openBtn.addEventListener("click", () => (overlay.hidden = false));
overlay.addEventListener("click", () => (overlay.hidden = true));
modal.addEventListener("click", (event) => {
event.stopPropagation(); // clicks inside the modal never reach the overlay
});
This works, but it has a hidden cost. Any other code that listens for clicks higher up (analytics, a “click outside” menu, a global shortcut) is now blind to everything that happens inside the modal.
The better fix: check target
Just ask, “Did the user click the overlay itself?”:
const overlay = document.getElementById("overlay");
const closeBtn = document.getElementById("close-btn");
const openBtn = document.getElementById("open-btn");
openBtn.addEventListener("click", () => (overlay.hidden = false));
closeBtn.addEventListener("click", () => (overlay.hidden = true));
overlay.addEventListener("click", function (event) {
if (event.target === event.currentTarget) {
overlay.hidden = true; // only when the overlay itself was clicked
}
});
event.target === event.currentTarget means “the thing clicked is the thing that owns this listener”, so clicks that bubbled up from the modal are ignored. No propagation is stopped, and everything stays predictable. Prefer this pattern.
Real use case: close a dropdown when clicking anywhere else
Another classic: a menu opens on a button click and closes when you click outside of it. Listen on the whole document and ask whether the click happened inside the menu area.
<div id="menu-wrap">
<button id="menu-btn">Menu ▾</button>
<div id="menu" hidden>Profile · Settings · Logout</div>
</div>
const wrap = document.getElementById("menu-wrap");
const menuBtn = document.getElementById("menu-btn");
const menu = document.getElementById("menu");
menuBtn.addEventListener("click", function () {
menu.hidden = !menu.hidden;
});
document.addEventListener("click", function (event) {
if (!wrap.contains(event.target)) {
menu.hidden = true; // the click happened outside the menu
}
});
This only works because click events bubble all the way up to document. If some other code called stopPropagation() on a click, this “click outside” listener would never hear it, and the menu would stay open. That is the main danger of stopPropagation.
Events that do not bubble
Not every event bubbles. You can check with event.bubbles.
Bubbles (true) |
Does not bubble (false) |
|---|---|
click, dblclick |
focus, blur |
input, change, submit |
mouseenter, mouseleave |
keydown, keyup |
load, unload |
mouseover, mouseout, mousemove |
scroll (on elements) |
focusin, focusout |
const btn = document.getElementById("btn");
btn.addEventListener("click", (e) => console.log("click bubbles:", e.bubbles)); // true
btn.addEventListener("focus", (e) => console.log("focus bubbles:", e.bubbles)); // false
Two useful facts:
focusandblurdo not bubble, butfocusinandfocusoutdo. Use those when you want one listener on a parent to react to focus changes in many inputs.mouseenterandmouseleavedo not bubble, butmouseoverandmouseoutdo.
Non-bubbling events still go through the capturing phase. So parent.addEventListener("focus", handler, true) also works to hear child focus events.
Should I use stopPropagation?
Rarely. It feels like a quick fix, but it can cause bugs that are hard to find, because parts of your code (and other people’s libraries) silently stop receiving events.
Use it when:
- You have one specific child that must not trigger its parent, and checking
event.targetis truly not possible. - You are building a self-contained widget and you know nothing else needs its events.
Prefer these safer options first:
- Check
event.target === event.currentTarget. - Check
event.target.closest("selector"). - Use
element.contains(event.target).
Careful: Do not confuse
stopPropagationwithpreventDefault.stopPropagationstops the event from travelling.preventDefaultstops the browser’s built-in action (like following a link). They do different jobs and you will meetpreventDefaultin its own lesson.
Common mistakes
- Not realising that the parent also hears the click. If a parent listener runs “by itself”, it is almost always bubbling.
- Using
stopPropagationas the first fix. It breaks other listeners, such as “click outside to close” code. Checkevent.targetinstead. - Mixing up
targetandcurrentTarget.targetis what was clicked.currentTargetis where your listener is attached. - Expecting
stopPropagationto stop other listeners on the same element. It does not. UsestopImmediatePropagationfor that. - Forgetting the order. Capturing goes top to bottom. Bubbling goes bottom to top. Bubbling is the default.
- Using
truewhen removing a bubbling listener, or the other way round. Thecapturevalue must match when you callremoveEventListener. - Expecting every event to bubble.
focus,blur,mouseenterandmouseleavedo not. Usefocusin,focusout,mouseoverandmouseout, or use capturing. - Calling
stopPropagation()and expecting the link not to open. That is the job ofpreventDefault(). - Putting the same logic on parent and child without realising both run. Decide which element owns the behaviour.
Practice
- Click Practice in Editor. Click the button and write down the order of the logs. Is it capturing first or bubbling first?
- Click the Middle box (not the button). Which listeners run now? Why does
btnnot appear? - Print
event.eventPhaseinside one capturing listener and one bubbling listener for the same click. - Print both
event.target.idandevent.currentTarget.idinside theouterlistener. Click each box and the button, and explain the results. - Uncomment the
stopPropagationline forinner. Click the button again. Which logs disappear? - Add two listeners to the button, and call
stopImmediatePropagation()in the first. Does the second run? - Build the modal: open it with the Open modal button and close it only when the dark overlay or the Close button is clicked, using
event.target === event.currentTarget. - Build the dropdown menu so it closes when you click anywhere outside of it. Then break it by adding
stopPropagation()to a click listener on thebodyand see what happens. - Print
event.bubblesfor aclickevent and for afocusevent on an input. What do you notice? - Challenge: use a capturing listener on
documentthat logs every click on the page together with the id of its target, without changing any other listener.
Recap
- When you click an element, the event travels through the DOM. This is event propagation.
- Three phases: capturing (down from
window), target (the clicked element), bubbling (back up towindow). addEventListeneruses bubbling by default. Passtrueor{ capture: true }to use capturing.event.targetis the element that was really clicked.event.currentTargetis the element whose listener is running.event.stopPropagation()stops the event from reaching the next elements.event.stopImmediatePropagation()also stops the other listeners on the same element.- Prefer checks like
event.target === event.currentTargetorelement.contains(event.target)over stopping propagation. - Some events (
focus,blur,mouseenter,mouseleave) do not bubble. Usefocusin,focusout,mouseover,mouseout, or capturing. - Next you will use bubbling for something powerful: event delegation, where one listener on a parent handles all of its children.