JavaScriptIntermediate

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.

All JavaScript lessons

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
  1. Capturing phase. The event starts at the very top (window) and travels down toward the element you clicked.
  2. Target phase. The event reaches the element that was actually clicked. This element is called the target.
  3. 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:

  1. It stops the event from reaching other elements (like stopPropagation).
  2. 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 &#9662;</button>
  <div id="menu" hidden>Profile &middot; Settings &middot; 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:

  • focus and blur do not bubble, but focusin and focusout do. Use those when you want one listener on a parent to react to focus changes in many inputs.
  • mouseenter and mouseleave do not bubble, but mouseover and mouseout do.

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.target is 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 stopPropagation with preventDefault. stopPropagation stops the event from travelling. preventDefault stops the browser’s built-in action (like following a link). They do different jobs and you will meet preventDefault in 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 stopPropagation as the first fix. It breaks other listeners, such as “click outside to close” code. Check event.target instead.
  • Mixing up target and currentTarget. target is what was clicked. currentTarget is where your listener is attached.
  • Expecting stopPropagation to stop other listeners on the same element. It does not. Use stopImmediatePropagation for that.
  • Forgetting the order. Capturing goes top to bottom. Bubbling goes bottom to top. Bubbling is the default.
  • Using true when removing a bubbling listener, or the other way round. The capture value must match when you call removeEventListener.
  • Expecting every event to bubble. focus, blur, mouseenter and mouseleave do not. Use focusin, focusout, mouseover and mouseout, or use capturing.
  • Calling stopPropagation() and expecting the link not to open. That is the job of preventDefault().
  • Putting the same logic on parent and child without realising both run. Decide which element owns the behaviour.

Practice

  1. Click Practice in Editor. Click the button and write down the order of the logs. Is it capturing first or bubbling first?
  2. Click the Middle box (not the button). Which listeners run now? Why does btn not appear?
  3. Print event.eventPhase inside one capturing listener and one bubbling listener for the same click.
  4. Print both event.target.id and event.currentTarget.id inside the outer listener. Click each box and the button, and explain the results.
  5. Uncomment the stopPropagation line for inner. Click the button again. Which logs disappear?
  6. Add two listeners to the button, and call stopImmediatePropagation() in the first. Does the second run?
  7. 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.
  8. 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 the body and see what happens.
  9. Print event.bubbles for a click event and for a focus event on an input. What do you notice?
  10. Challenge: use a capturing listener on document that 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 to window).
  • addEventListener uses bubbling by default. Pass true or { capture: true } to use capturing.
  • event.target is the element that was really clicked. event.currentTarget is 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.currentTarget or element.contains(event.target) over stopping propagation.
  • Some events (focus, blur, mouseenter, mouseleave) do not bubble. Use focusin, 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.