Bubbling & capturing
Learn how browser events travel through nested elements, how target differs from currentTarget, and when stopPropagation helps or hurts.
- 01Follow the tripSay in what order capture, target, and bubble listeners run when you click an element inside other elements.
- 02Read the event objectUse
target,currentTarget,eventPhase, andcomposedPath()to see where an event started and where it is now. - 03Stop with carePick the right one of
stopPropagation,stopImmediatePropagation, andpreventDefault.
The trip an event takes
On a web page, elements sit inside other elements. A button can sit inside a card. The card can sit inside a section. The elements around the button (its parent, the parent's parent, and so on) are called its ancestors.
When you click the button, the browser does not tell only the button. It makes one event object: an object that describes the click. Then it carries that object on a trip through the button's ancestors. The trip has three parts, called phases:
- Capture: the event goes in, from the outside down to the button.
- Target: the event reaches the button, the element you actually clicked.
- Bubble: the event goes back out, from the button up to the outside.
A listener is a function you add with addEventListener. Any element on the trip can have listeners, so any of them can react to the click. This explains three everyday things:
- A card can hear a click on a button inside it.
- A menu can close when you click anywhere else on the page.
- One call to
stopPropagation()can break code far away from that line.
A courier brings a package to an apartment. The courier walks in through the lobby, goes up to the right floor, and reaches the apartment. After the delivery, the news travels back the other way: apartment, then floor, then lobby. A click takes the same kind of trip through elements that sit inside each other.
- In real life: The courier walks in through the lobby
- In JavaScript: Capture starts on the outside:
window,document, then the section - In real life: The courier reaches the apartment
- In JavaScript: The target phase, on the clicked element
- In real life: The news goes back to the floor and the lobby
- In JavaScript: The bubble phase goes back out through the ancestors
- In real life: Anyone on the way can react
- In JavaScript: Any ancestor with a listener can run code
Event propagation is the trip one event object takes. It goes in through the ancestors to the clicked element. Then it goes back out through the same ancestors.
Capture, target, and bubble phases
STEP THROUGHYou choose which half of the trip a listener joins. Pass { capture: true } as the third argument to addEventListener, and the listener runs on the way in. Leave the third argument out, and it runs at the target or on the way out.
section.addEventListener("click", log, { capture: true });card.addEventListener("click", log, { capture: true });button.addEventListener("click", log, { capture: true }); button.addEventListener("click", log);card.addEventListener("click", log);section.addEventListener("click", log);Lines 1 to 3 add capture listeners, so they run on the way in. Lines 5 to 7 leave the option out, so they run on the way out. Each line adds the same log function to one element.
section down to the button. Then it goes back out, from the button up to section.const card = document.querySelector("[data-box='card']"); card.addEventListener("click", () => console.log("capture"), { capture: true });card.addEventListener("click", () => console.log("bubble"));document.querySelector("[data-box='button']").click();This example prints capture, then bubble. Line 3 passes { capture: true }, so its listener runs on the way in. Line 4 leaves the option out, so its listener runs on the way out. Line 5 clicks the button inside the card.
Older code writes true as the third argument instead. It means the same thing. The object form is easier to read, and it can hold other options too, such as once.
const order = [];const card = document.querySelector("[data-box='card']");const button = document.querySelector("[data-box='button']"); card.addEventListener("click", () => order.push("card capture"), { capture: true });button.addEventListener("click", () => order.push("button target"));card.addEventListener("click", () => order.push("card bubble"));button.click();console.log(order.join(" > "));This prints card capture > button target > card bubble. Line 5 runs first, because capture goes in from the outside. Line 6 runs next, on the button, the target. Line 7 runs last, when the event bubbles back out to the card. To guess the order, draw the trip first.
The full order for a click on the button is:
- Capture listeners run from the outside in.
- The button's own listeners run. Its capture listeners run before its normal ones in today's browsers. Older browsers did not always agree, so don't write code that needs this exact order.
- Normal listeners run from the inside out. This only happens if the event's
bubblesproperty istrue.
The table lists the three phases. Read each row as: "in this phase, the event goes this way, eventPhase is this number, and this is how you listen."
| Phase | Which way does it go? | What is eventPhase? | How do you listen? |
|---|---|---|---|
| Capture | In: window, then document, then each ancestor down to the parent. | 1, also called CAPTURING_PHASE. | Pass { capture: true } or true. |
| Target | It stays on the clicked element. | 2, also called AT_TARGET. | Both kinds run. Capture listeners go first in today's browsers. |
| Bubble | Out: the parent, then each ancestor up to document, then window. | 3, also called BUBBLING_PHASE. | Leave the option out. It only runs if event.bubbles is true. |
The step-through below does not use a real page. It is a small plain-JavaScript copy of the trip, with a section, a card, a toolbar, a button, and an icon inside the button. Step through it to see the order. Then compare it with the real browser in the next section.
That is the whole trip. Pick a different stop above and step through again, then compare with the real page in the next section.
script
const path = ["section", "card", "toolbar", "button", "icon"];const listeners = ["capture", "bubble"]; for (const node of path.slice(0, -1)) {} runListener("button", "target capture");runListener("button", "target bubble"); for (const node of ["toolbar", "card", "section"]) { runListener(node, "bubble");}Experiment: watch the trip in a real page
INTERACTIVENow try the browser's real events. The demo shows four boxes, one inside the other: a section, a card, a toolbar, and a button with a star in the middle. Each box has two bells:
- ↓ in rings when the box's capture listener runs, on the way in.
- ↑ out rings when the box's normal listener runs, on the way out.
Click the star and watch the bells ring in order. The numbers on the bells, and the row under the boxes, show the order.
stopPropagation()Click the star. The click travels in through each box, from the outside to the star, then back out again. Each bell rings when its box's listener runs.
Tap a bell to turn it off, or tap it again to give it a stop sign. A stop sign means that listener calls event.stopPropagation(). Then click the star again: every bell after the stop sign stays silent.
Game: guess the order
Now test yourself. Each level turns on a few bells. Tap them in the order you think they will ring, then click the star. The real click checks your guess. Get it right on the first try for three stars.
stopPropagation()The section listens both ways. Tap the bells in the order you think they'll ring. Then click the star to send a real click.
The demo and the game both add real listeners, like this:
const boxes = document.querySelectorAll("[data-box]"); for (const box of boxes) { box.addEventListener("click", ringIn, { capture: true }); box.addEventListener("click", ringOut);} function ringIn(event) { console.log(event.currentTarget.dataset.box, "in");} function ringOut(event) { console.log(event.currentTarget.dataset.box, "out");}target vs currentTarget
The event object has two properties that are easy to mix up:
event.targetis the element where the event started: the deepest element you clicked.event.currentTargetis the element whose listener is running right now.
In the demo, click the star. The star is a span inside the button. In the section's log line, target is still the span, but currentTarget is the section.
const card = document.querySelector("[data-box='card']"); card.addEventListener("click", (event) => { console.log(event.target.dataset.box); console.log(event.currentTarget.dataset.box);}); document.querySelector("[data-box='button']").click();This prints button, then card. Line 8 clicks the button, so the button is the target. The listener belongs to the card, so the card is the currentTarget. As the event moves on, currentTarget changes to each new element. target stays the same.
target is the button and currentTarget is the card.Picture a set of nesting dolls, one inside the other. Poke the smallest doll in the middle, and every doll around it feels the poke. The spot you poked stays the same. The doll that is feeling it right now keeps changing.
- In real life: You poke the smallest doll
- In JavaScript:
event.targetis the clicked span - In real life: Each bigger doll feels it in turn
- In JavaScript: Each ancestor's listener runs, with its own
currentTarget - In real life: The spot you poked never moves
- In JavaScript:
targetstays the same;currentTargetchanges
The table compares three things you can read from the event. Read each row as: "this property tells you this, and in the star demo it is this."
| What you read | What does it tell you? | What is it when you click the star? |
|---|---|---|
event.target | The element where the event started. | It is the span with the star, every time. |
event.currentTarget | The element whose listener is running now. | It is the section, card, toolbar, or button, depending on the log line. |
event.composedPath() | The list of elements on the event's trip. | It starts at the span and lists each ancestor after it. |
const button = document.querySelector("[data-box='button']"); button.addEventListener("click", (event) => { console.log(event.composedPath().slice(0, 3).map((node) => node.dataset.box || node.tagName).join(" > "));}); button.click();This prints button > toolbar > card. composedPath() returns an array of the elements the event passes through. It starts at the target and goes outward. Line 4 keeps the first three and prints their data-box names.
It also works with shadow DOM, a way for a component to hide its inner elements. There, walking up with parentElement can miss steps, but composedPath() still lists the whole trip.
Inside a listener, use event.currentTarget when you mean "the element I added this listener to." Use event.target only when you need the inner element that was clicked.
stopPropagation and stopImmediatePropagation
COMPARESometimes a listener needs to end the trip. For example, a dialog box may want to keep its own clicks away from a page listener that closes it. event.stopPropagation() does this: the event does not go on to any more elements.
It does not stop other listeners on the same element. They still run.
News reaches one floor of a building. You close the stairwell door, so the news can't travel to other floors. But the people already in that room can still hear it. To keep them from hearing it too, you would also have to ask the room to stay quiet.
- In real life: Close the stairwell door
- In JavaScript:
stopPropagation()keeps the event from other elements - In real life: People in the same room still hear the news
- In JavaScript: Other listeners on the same element still run
- In real life: Also ask the room to stay quiet
- In JavaScript:
stopImmediatePropagation()skips the rest of this element's listeners
Three methods have similar names. The table shows what each one stops. Read each row as: "this method stops these things, and not the others."
| Method | Does it stop later elements? | Does it stop other listeners on this element? | Does it stop the browser's built-in action? |
|---|---|---|---|
stopPropagation() | Yes. | No. | No. |
stopImmediatePropagation() | Yes. | Yes. | No. |
preventDefault() | No. | No. | Yes, if the event allows it to be cancelled. |
The browser's built-in action is what it does on its own, such as following a link or sending a form. Only preventDefault() stops that.
const button = document.querySelector("[data-box='button']");const log = []; button.addEventListener("click", (event) => { log.push("first"); event.stopImmediatePropagation();});button.addEventListener("click", () => log.push("second"));button.click();console.log(log.join(" > "));This prints only first. The button has two listeners. The first one calls stopImmediatePropagation() on line 6. So the second listener, on line 8, is skipped, and no ancestor hears the click. If line 6 called stopPropagation() instead, both button listeners would run and it would print first > second. The ancestors would still hear nothing.
stopImmediatePropagation() also skips the second listener on the same button.Many features need to hear clicks from inside other elements: menus that close when you click outside, analytics that count clicks, and keyboard shortcuts. The next lesson, Event delegation, also depends on the trip. Stop it only when you know exactly which ancestor must not hear the event, and why.
Events that do not bubble
REAL BROWSEREvery event goes through the capture phase. But only events whose bubbles property is true go back out. Some common events don't bubble, and each has a partner that does:
focusandblurdon't bubble.focusinandfocusoutdo.mouseenterandmouseleavedon't bubble.mouseoverandmouseoutdo.
panel.addEventListener("focus", log, true);panel.addEventListener("focus", log);panel.addEventListener("focusin", log); panel.addEventListener("mouseenter", log, true);panel.addEventListener("mouseenter", log);panel.addEventListener("mouseover", log); function log(event) { console.log(event.type, event.bubbles);}Click the field, press Tab to leave it, and move the pointer over the button. Each log shows event.bubbles. Capture listeners on the box hear events from the elements inside it. Normal listeners on the box hear them only if they bubble.
event.bubbles value.const card = document.querySelector("[data-box='card']");const button = document.querySelector("[data-box='button']");const log = []; card.addEventListener("focus", () => log.push("focus capture"), true);card.addEventListener("focus", () => log.push("focus bubble"));card.addEventListener("focusin", () => log.push("focusin bubble"));button.focus();console.log(log.join(" > "));This prints focus capture > focusin bubble. Line 8 puts the focus on the button inside the card.
- Line 5 is a capture listener, so it hears
focuson the way in. - Line 6 is a normal listener for
focus. It never runs, becausefocusdoes not bubble. - Line 7 listens for
focusin, which bubbles, so it runs.
So when a form wrapper needs to know that focus went into one of its fields, listen for focusin and focusout.
focus stays on the button, so the card's normal listener never runs. focusin bubbles up, so the card hears it.const card = document.querySelector("[data-box='card']");const button = document.querySelector("[data-box='button']");const log = [];const record = (event) => log.push(event.type + " " + event.bubbles); card.addEventListener("mouseenter", record, true);card.addEventListener("mouseenter", record);card.addEventListener("mouseover", record);button.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));console.log(log.join(" > "));This prints mouseenter false > mouseover true. Each log shows the event's name and its bubbles value. Lines 9 and 10 send the two events to the button.
- Line 6 is a capture listener, so it hears
mouseenteron the way in. - Line 7 is a normal listener for
mouseenter. It never runs, becausemouseenterdoes not bubble. - Line 8 listens for
mouseover, which bubbles, so it runs.
Use mouseover when an ancestor should hear the pointer moving onto its inner elements. Use mouseenter when you want one event per element, with no bubbling.
clickfocusinmouseoverfocusblurmouseentermouseleavefocusout
Sort each event into the right group. Use the demo above to check the browser's bubbles value.
Where you will use this
You will use the trip in real code. It lets one listener handle a whole area of the page. It is also why one part of a page can block another part by accident.
const menu = document.querySelector(".menu");const button = document.querySelector(".menu-button"); document.addEventListener("click", (event) => { const clickedInside = menu.contains(event.target) || button.contains(event.target); if (!clickedInside) menu.hidden = true;}); button.addEventListener("click", (event) => { event.stopPropagation(); menu.hidden = !menu.hidden;});- Lines 4 to 7 add a listener to the whole
document. Clicks anywhere bubble up to it. - Line 5 checks whether the click was inside the menu or its button.
contains()returnstrueif the element is the same one or sits inside it. - Line 6 hides the menu if the click was outside.
- Line 10 stops the menu button's own click, because a click on the button is not an outside click.
In a bigger app, you might skip the stop on line 10. Instead, the document listener can check the trip with composedPath().
const log = [];const card = document.querySelector("[data-box='card']");const button = document.querySelector("[data-box='button']"); card.addEventListener("click", () => log.push("analytics"));button.addEventListener("click", (event) => { event.stopPropagation(); log.push("button");});button.click();console.log(log.join(" > "));This prints button, not button > analytics. Line 5 gives the card a listener that records clicks for analytics. But line 7 stops the event at the button, before it can bubble up to the card. So the analytics listener never records the click.
In a real app, a stop like this can quietly break analytics, keyboard shortcuts, or a menu that closes on outside clicks. When you can, let the event bubble and add a check in the ancestor's listener instead.
document.body.innerHTML = '<button class="open-modal">Open</button><div class="modal" hidden>Details</div>';const modal = document.querySelector(".modal");const openButton = document.querySelector(".open-modal"); document.addEventListener("click", (event) => { if (!event.composedPath().includes(modal) && !event.composedPath().includes(openButton)) { modal.hidden = true; }});openButton.addEventListener("click", () => { modal.hidden = false; });openButton.click();document.body.click();console.log(modal.hidden);This prints true, which means the modal is hidden again. A modal is a box that opens on top of the page.
- Line 11 clicks the Open button, and its listener on line 10 shows the modal.
- That click also bubbles to the document. Line 6 finds the Open button in the trip, so the modal stays open.
- Line 12 clicks the page body. That click bubbles to the document listener on line 5.
- Line 6 checks the trip. Neither the modal nor the Open button is in it, so line 7 hides the modal.
This code never stops the trip, so other document listeners still hear every click. In a real app, also close the modal when the user presses Escape, and move the focus back to the Open button.
section capturecard capturebutton capturebutton bubbletoolbar bubblesection bubble
The user clicks the button. Put each listener in the right group: before the target, at the target, or after the target.
Common misconceptions
"Bubbling makes a copy of the event for each element."
There is only one event object. As it moves, its currentTarget and eventPhase change.
"Capture only happens for events that bubble."
Capture happens even when bubbles is false. The focus example shows this: the capture listener ran.
"stopPropagation stops everything."
It only stops later elements. Other listeners on the same element still run, and the browser's built-in action still happens.
"target and currentTarget are the same thing."
They are the same only in a listener on the clicked element itself. In an ancestor's listener, they are different.
"If the parent hears a child's click, something went wrong."
No. That is normal bubbling, and it is often exactly what you want.
Practice: follow the trip
6 EXERCISESWhat number is event.eventPhase during the bubble phase?
event.eventPhase is 3 during the bubble phase. Its name is Event.BUBBLING_PHASE.
You click the icon <span> inside a button. What is event.target?
The star is a span inside the button, so event.target is that span. In the button's listener, currentTarget is the button.
These three logs show the order for a click on the button. What does the first one print?
console.log("section capture");
console.log("button target");
console.log("section bubble");The first log is section capture. Capture goes in from the outside, so the section's capture listener runs before the target and before bubbling.
Which method keeps the event from reaching later elements, but still lets the other listeners on this element run?
Use stopPropagation(). The event does not reach any more elements, but other listeners on this element still run. Use stopImmediatePropagation() only when they must be skipped too.
A teammate used stopPropagation() to keep a link from opening its page. The link still opens. Which method should they use instead?
link.addEventListener("click", (event) => {
event.stopPropagation();
openInPanel(link.href);
});link.addEventListener("click", (event) => {
event.preventDefault();
openInPanel(link.href);
});Use preventDefault() to stop the browser's built-in action, such as following a link or sending a form. Stopping the trip only decides which listeners hear the event, so the link still opens.
A card records analytics in its click listener. A button inside the card opens details, but it calls stopPropagation(). What should you remove so the card still records the click?
card.addEventListener("click", recordAnalytics);
button.addEventListener("click", (event) => {
event.stopPropagation();
openDetails();
});Remove the stopPropagation() call, unless the button really must hide its clicks from an ancestor. Then the click bubbles up, and the card's analytics listener records it. If some clicks should not count, let the card's listener check for them.
Quiz: check your understanding
8 QUESTIONSTry each question once before you read the explanations.
Question 1 of 8You click a button inside a card. Which listeners run first?
Choose an answer to see the explanation.
Question 2 of 8These logs show the order for a click on the button. What is the first line printed?
Read the code, then predictconsole.log("section capture"); console.log("button target"); console.log("section bubble");Choose an answer to see the explanation.
Question 3 of 8You click a
spaninside abutton. In a listener added to the button, what isevent.currentTarget?Choose an answer to see the explanation.
Question 4 of 8Which property of the event object is 1 during capture, 2 at the target, and 3 during bubble?
Choose an answer to see the explanation.
Question 5 of 8What does
stopPropagation()do?Choose an answer to see the explanation.
Question 6 of 8Which pair of focus events bubbles?
Choose an answer to see the explanation.
Question 7 of 8Why should you not call
stopPropagation()everywhere?Choose an answer to see the explanation.
Question 8 of 8What does
composedPath()show you?Choose an answer to see the explanation.
Key takeaways
- An event takes a trip in three phases: capture (in), target, then bubble (out).
event.targetis where the event started.event.currentTargetis the element whose listener is running.eventPhaseis 1 for capture, 2 at the target, and 3 for bubble.composedPath()lists the whole trip.stopPropagation()stops later elements.stopImmediatePropagation()also skips this element's other listeners.preventDefault()stops the browser's built-in action.- Some events don't bubble. When an ancestor must hear them, use the partners that do:
focusin/focusoutandmouseover/mouseout.
Remember this.
One event object takes a trip through the page: in during capture, at the target, then out during bubble.
Up next: Event delegation. You will use bubbling on purpose, so one listener can handle many elements.