You Might Also Like
Event Bubbling Visualizer — Free HTML CSS JS Snippet
Event Bubbling Visualizer · Animations · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Event Bubbling Visualizer — Animated Capture & Bubble Phase Demo with stopPropagation() in Vanilla JS

The DOM event model has three phases — capture, target, and bubble — and almost every explanation of it draws a static arrow diagram instead of showing an actual click event traveling through actual nested elements. This snippet fixes that: three real nested boxes (Outer, Middle, Inner) each carry genuine addEventListener handlers registered in both phases, a live log prints exactly which handler fires and in what order, and an animated pulse physically travels down through the tree during capture and back up during bubble — using the browser's real event dispatch, not a scripted approximation of it.
Real capture and bubble listeners, not a simulated order
Each box registers two listeners on the exact same element: addEventListener('click', handler, true) for the capture phase and addEventListener('click', handler, false) (the default) for the bubble phase. When Inner is clicked, the browser's actual dispatch algorithm runs: it walks from document down to inner calling every registered capture-phase listener along the way (outer capture, then middle capture, then inner capture), then calls the target's own bubble-phase listener, then walks back up calling every bubble-phase listener from inner to middle to outer. The log entries this snippet prints are not scripted to look like this order — they are the direct, real-time record of the callbacks the browser itself invoked, which is why toggling any of the six checkboxes (capture/bubble per box) genuinely changes which lines appear, because it genuinely changes which listeners exist.
Two animated pulses on the same physical positions
runVisualPulse() computes the screen-space center of each box with getBoundingClientRect() and animates a small SVG <circle> through those points using requestAnimationFrame with a cubic ease-out (1 - Math.pow(1 - p, 3)), not CSS keyframes, because the destination points depend on live layout measurements taken at click time rather than fixed values that could be hardcoded into a @keyframes rule. The first pulse (grey, thinner, radius 4) runs Inner → Middle → Outer to represent the capture phase's top-down path rendered in reverse for legibility, immediately followed by a second pulse (indigo, thicker, radius 6) that runs the same Inner → Middle → Outer path representing the bubble phase — the phase most real-world event listeners actually use, which is why it is drawn heavier and brighter.
Why capture is drawn dim and bubble is drawn bright
This is a deliberate legibility choice, not an accident: capture-phase listeners are comparatively rare in real code (most handlers use the default bubble phase), so demoting the capture pulse to a thin grey line keeps visual weight on the phase developers actually rely on day to day, while still making the true dispatch order — capture always completes fully before bubble begins — visible as two distinct, sequential animations rather than one ambiguous blur.
The stopPropagation() toggle and where it actually breaks the chain
Checking "stopPropagation()" on the Middle box's bubble-phase checkbox flips a flag that is read inside Middle's own bubble handler: if (name === 'middle' && stopChecked) { e.stopPropagation(); }. Calling stopPropagation() from inside a handler stops the event from continuing to any further listeners in the remaining phase — since this call happens during Middle's *bubble* handler, it prevents Outer's bubble listener from ever firing, but it does not retroactively undo the capture phase, which already ran to completion before bubbling even started. runVisualPulse() mirrors this precisely: when the stop toggle and Middle's bubble checkbox are both on, the second (bubble) pulse animation is only given two points, [innerPt, middlePt], so it visibly halts at Middle instead of continuing to Outer — and the log shows the real consequence, with no "OUTER — bubble phase handler fired" line ever appearing.
Independent per-box, per-phase toggles built for direct experimentation
Rather than one global on/off switch, each box exposes its own capture and bubble checkboxes (six total), checked via document.getElementById(name + '-capture').checked and ...'-bubble'.checked at the top of every handler before it logs anything or runs its side effects. This lets you construct any combination — for example, disabling Inner's own bubble listener while keeping Middle's and Outer's — and watch the log and pulse respond exactly as the real DOM event model dictates for that specific configuration, rather than only ever observing the one "all phases enabled" case most diagrams show.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Give this snippet's JavaScript to an AI assistant like Claude and ask it to trace exactly why the stopPropagation() call inside Middle's bubble handler prevents Outer's bubble listener but not Middle's own already-completed capture listener — the phase-ordering logic is subtle and worth confirming you've internalized correctly. It's also worth asking how this would change if stopImmediatePropagation() were used instead, or if a capture-phase stopPropagation() call were added on Middle as well. Good extensions to request: a fourth nesting level, a visible phase-progress indicator (capture/target/bubble label) synced to the pulse, or a second simultaneous click target to show two independent dispatch paths.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build an animated event bubbling and capturing visualizer in plain HTML, CSS, and JavaScript, no frameworks or libraries.
Requirements:
- Three nested boxes labeled Outer, Middle, and Inner, each with its own real addEventListener('click', handler, true) capture-phase listener and addEventListener('click', handler, false) bubble-phase listener — the actual browser event dispatch order must drive everything, not a scripted sequence.
- A checkbox per box per phase (six total) that enables or disables that specific listener before a click happens, so any combination of active capture/bubble handlers across the three boxes can be tested.
- Clicking the Inner box triggers two sequential animated pulses that travel between the real screen positions of the three boxes (measured with getBoundingClientRect at click time, not hardcoded coordinates): first a dim, thin pulse representing the capture phase traveling from Outer down to Inner, then a brighter, thicker pulse representing the bubble phase traveling from Inner back up to Outer.
- A live, scrolling event log that prints one line per handler invocation in the real order the browser called them, visually distinguishing capture-phase entries from bubble-phase entries.
- A stopPropagation() checkbox on the Middle box's bubble-phase handler that, when enabled, must genuinely call event.stopPropagation() inside that handler — causing the bubble-phase pulse animation to visibly halt at Middle instead of continuing to Outer, and the log to show that Outer's bubble handler never fires.
- Use requestAnimationFrame with an easing function for the pulse movement rather than fixed CSS keyframe animations, since the pulse's start and end coordinates depend on live layout measurements taken at the moment of the click.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Click the Inner boxA dim grey pulse travels Outer to Middle to Inner during the capture phase (drawn first), followed by a brighter indigo pulse traveling Inner to Middle to Outer during the bubble phase.
- 2Read the event log on the rightEach line records a real handler firing, in the exact order the browser invoked it — capture-phase entries appear grey and thin, bubble-phase entries appear indigo and bold.
- 3Uncheck a box's "bubble" checkboxThat box's bubble-phase listener is removed entirely. Click Inner again and confirm no log entry appears for that box during the bubble pass, while capture (if enabled) still fires normally.
- 4Check a box's "capture" checkboxThat box's capture-phase listener is added. Click Inner again and watch a new grey log entry appear before any bubble entries, since capture always completes before bubbling begins.
- 5Enable stopPropagation() on the Middle boxClick Inner again. The bright bubble pulse now visibly stops at Middle instead of continuing to Outer, and the log confirms Outer's bubble handler never fires.
- 6Toggle stopPropagation() off and compare the two log sequencesWith it off, all six potential handlers can fire in full capture-then-bubble order; with it on, exactly one bubble-phase handler (Outer's) is missing from the second run — the clearest possible before/after contrast.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Yes, with one important adjustment: React's synthetic event system attaches a single delegated listener at the root and simulates bubbling through its own virtual tree, so onClickCapture and onClick props on JSX elements map to this snippet's capture and bubble listeners respectively, and calling e.stopPropagation() inside a React handler behaves the same way described here. Put the pulse animation loop (the requestAnimationFrame-based animateTo function) inside a useEffect or a plain event handler, and since it resolves via its own Promise chain rather than a persistent interval, there is no dangling timer to clear on unmount — just guard against updating state after unmount if the component could disappear mid-animation. Vue's @click.capture modifier and Angular's (click) with a manually attached capture listener follow the same conceptual mapping.
Because that mirrors the real DOM Level 3 event dispatch algorithm: the browser first walks from the document root down to the actual click target, invoking every capture-phase listener it encounters along the way, and only once that entire descent is complete does it begin the ascent back up, invoking bubble-phase listeners. There is no interleaving between the two phases — capture always fully completes first, which is exactly why this snippet always plays the grey pulse to completion before starting the indigo one.
No. stopPropagation() only prevents the event from continuing to listeners that have not yet run, at the point in the phase sequence where it is called. Since this snippet calls it from inside Middle's bubble-phase handler, Middle's own capture-phase listener (which ran earlier, during the descent) is unaffected — it already fired. What gets prevented is only Outer's bubble-phase listener, the next one in line after Middle's bubble handler runs.
stopPropagation() prevents the event from reaching listeners on other elements further along the capture or bubble path, but any other listeners already registered on the same element for the same event will still run. stopImmediatePropagation() does both of those things and additionally prevents any remaining listeners on the very same element from running, even ones registered for the same phase. This snippet only demonstrates stopPropagation(), since only one bubble listener is ever registered per element here — adding a second bubble listener to Middle and calling stopImmediatePropagation() instead would be a natural extension to see the extra effect.
Because whichever element you click becomes the actual event target, and only elements from that target up to the document root (or down to it, for capture) are part of that particular dispatch's path. Clicking Middle directly means Inner is never part of the path at all, so only Middle's and Outer's handlers (and Middle's own target-phase handler) can fire — Inner's listeners are only ever invoked when Inner itself, or one of Inner's own descendants, is what was actually clicked.