Interaction, from the event out

Library/Interaction, from the event out

28 Aug 2026

A click is not a moment. It is a journey. The browser takes the one place you touched and walks it up and down the tree of elements, offering it to a listener at every stop, and the order it visits them decides which of your handlers wins. This guide is about that machine: the layer underneath React that runs your JavaScript, measures the page, and moves things — the one your framework is quietly driving for you. It starts where every interaction starts: with a single event, and the path it walks. Then the loop that events run inside, the rulers you measure the page with, the transforms and the compositor that move boxes cheaply, and the pointer model that turns a drag on a laptop and a drag on a phone into one thing. By the end you will have built a canvas you can pan, zoom and drag nodes around on, from the primitives with no library, and then opened React Flow's own source to find it doing exactly what you just did. Every number here was measured in a real browser, not remembered. We start with one event.

/ the event

The event, and where it goes

Here is a toolbar. Inside it, a button. Inside the button, a little <span> holding the icon. Every one of those three has a click handler. You click the icon. Which handler runs first — the toolbar's, or the icon's?

html · an icon, in a button, in a toolbar — all three listening
<div id="toolbar">
  <button id="delete">
    <span id="icon">x</span>
  </button>
</div>

Most people answer "the icon's": you clicked the icon, so its handler goes first, then the click bubbles up to the button and the toolbar. That is half right, and the half that is wrong is the half that bites. Here is what actually happens when every element registers a handler for both directions the event travels. The tree below is a shade deeper than the toolbar — a grandparent, a parent, a target button, a child span — but the shape is the same.

chromium 151 · every node listening in both phases; click dispatched on the innermost child
  gp CAPTURE target=ch current=gp
  par CAPTURE target=ch current=par
  tgt CAPTURE target=ch current=tgt
  ch CAPTURE target=ch current=ch
  ch BUBBLE  target=ch current=ch
  tgt BUBBLE  target=ch current=tgt
  par BUBBLE  target=ch current=par
  gp BUBBLE  target=ch current=gp

Read the first column top to bottom. The event does not start at the thing you clicked. It starts at the root and travels down — grandparent, parent, target, child — and only then turns around and travels back up. The click visited the toolbar before it ever reached the icon.

Three phases, one trip

The browser dispatches an event in three phases, and they are one continuous trip from the top of the tree to your element and back.

The trip down is the capturing phase: the event descends from the document root toward the element, stopping at each ancestor on the way. The moment it reaches the element you actually clicked is the target phase. Then it reverses and climbs back up through the same ancestors, the bubbling phase. Capture runs root→target; bubble runs target→root; the target sits at the fold in the middle.

By default, a listener you add with element.addEventListener('click', fn) runs in the bubbling phase — the trip back up. That is why the folk model works most of the time: bubbling really does go innermost-first. The capturing phase exists too, but you only listen in it if you ask, by passing { capture: true }. That flag is the whole difference, and the spec says exactly what it buys you:

When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE.

So a plain listener hears the event on the way up; a capturing listener hears it on the way down. There are four phase values in all. The spec lists NONE, CAPTURING_PHASE, AT_TARGET, and BUBBLING_PHASE, and NONE is just the value before dispatch starts.

target is where it happened; currentTarget is who is listening

Look at the other two columns in that output. On every single line, target is ch — the child span, the thing actually clicked. It never changes. But current changes on every line: it is gp, then par, then tgt, then ch, and back up. Those are two different questions the event answers at once.

event.target is where the event originated, the deepest element the click landed on. It is the same for every listener, because it describes the event, not the listener.

event.currentTarget is whose listener is running right now: the element you attached this handler to. It is the element in front of the . when you called addEventListener.

When you click the icon, the toolbar's handler fires with currentTarget being the toolbar but target still being the icon. That gap is not a quirk to memorise; it is the entire basis of the pattern in the next chapter, where one listener on a parent handles clicks on a hundred children by reading target to find out which child it was. Hold onto the distinction: target is where it happened, currentTarget is who is hearing about it.

So a click is a round trip — down through the ancestors, a turn at the element, and back up — and at each stop the same event reports one fixed origin and one moving listener. Knowing which phase a handler runs in, and which of those two "target" words you want, is most of reading event code correctly. The next chapter is about stopping the trip, cancelling what the event was going to do, and using the round trip to your advantage.

Stop, prevent, delegate

You have a checkbox inside a row, and clicking anywhere on the row should select it. So you put a click handler on the row. Now clicking the checkbox does two things you did not fully intend: it toggles the checkbox and it fires the row's handler. You want to stop one of those. Which word do you reach for — preventDefault, or stopPropagation?

People reach for whichever they remember, because both feel like "make it stop". They are not the same machine, and they do not touch the same thing. There are three verbs here, not one, and they act on three different parts of the trip you just watched an event take.

The three verbs, measured apart

Take one node with two click handlers, sitting under an ancestor that also listens. Call each verb in the first handler and watch what still runs.

chromium 151 · each verb, its effect isolated
stopPropagation():          ["b listener1 -> stopPropagation", "b listener2 (STILL runs)"]
stopImmediatePropagation(): ["b listener1 -> stopImmediatePropagation"]
preventDefault():           {"checked": false, "without it": true, "defaultPrevented": true}

Three verbs, three different reaches.

preventDefault() cancels the default action — the thing the browser was going to do on its own. A checkbox toggles, a link navigates, a form submits. Call it and the checkbox stays unchecked (checked: false) while everything else about the event carries on exactly as before. It says nothing about propagation. The event still travels the whole tree; it just arrives having been told the browser's built-in response is off.

stopPropagation() cancels the travel. The event stops moving to the next node — no more ancestors on the way up, no more descendants on the way down. The spec puts it plainly: it "prevents event from reaching any objects other than the current object." But notice the second entry in the output: b listener2 still runs. stopPropagation stops the event leaving this node; it does not stop the other listeners on this same node from finishing.

stopImmediatePropagation() is the bigger hammer. It stops the travel and the remaining listeners on the current node — the spec says it prevents the event "from reaching any registered event listeners after the current one finishes running." That is why the third line shows only listener1: listener2, registered on the same node, never got to run.

The one that surprises people is that preventDefault and stopPropagation are orthogonal. Cancelling the checkbox toggle does nothing to bubbling; stopping the bubbling does nothing to the toggle. jQuery's old return false did both at once, which is exactly why a generation of developers came to think of them as one "cancel". They were always two.

Delegation: one listener for a hundred rows

Now put the round trip to work. You have a list of a hundred items, and each needs a click handler. You could attach a hundred listeners. Or you could attach one, to the list, and let bubbling bring every click up to it — then read event.target to find out which row it was.

chromium 151 · one listener on the `
    `, clicks on a nested `` and a bare `
  • `
  delegated: clicked li data-id=1 (real target was <b>)
  delegated: clicked li data-id=3 (real target was <li>)
  total listeners on the <ul>: 1

One listener on the <ul> caught both clicks. When the click landed on a <b> nested inside a row, event.target was that <b> — not the row. So the handler walks up from wherever the click landed to the nearest <li>, using closest:

js · the whole pattern
list.addEventListener('click', (e) => {
  const row = e.target.closest('li');   // from wherever it hit, find the row
  if (!row) return;                     // clicked the gap between rows
  select(row.dataset.id);
});

This is the two ideas from the last chapter cashing out together. Bubbling is what carries a click on a deep child all the way up to the one listener. event.target is what tells that listener where the click really landed, so closest('li') can resolve it back to a row. It is called event delegation — one listener on an ancestor, delegating the "which one?" question to target. It keeps working when you add rows later, because the rows never had listeners of their own; the parent had the only one all along.

One listener that can't cancel: passive

There is a case where preventDefault quietly does nothing at all, and it is worth meeting now even though its full story comes later. Add a wheel listener to the page and try to cancel the scroll from inside it.

chromium 151 · preventDefault inside a passive listener
  non-passive listener -> event.defaultPrevented = true
  passive listener     -> event.defaultPrevented = false (preventDefault ignored)
  [error] Unable to preventDefault inside passive event listener invocation.

A passive listener is one you have promised will not call preventDefault. When you make that promise, the browser holds you to it: preventDefault inside becomes a no-op, defaultPrevented stays false, and Chromium logs a warning. You can opt in explicitly with { passive: true } — but here is the trap. For wheel, touchstart, touchmove and mousewheel, added to the window, document or body, the browser makes the listener passive by default, without you asking. The DOM spec spells out the exact set:

Return true if all of the following are true: type is one of "touchstart", "touchmove", "wheel", or "mousewheel" … eventTarget is a Window object, or is a node whose node document is eventTarget … or … whose node document's body element is eventTarget.

So document.addEventListener('wheel', fn) is passive already, and a preventDefault in it is silently ignored. This is the real cause behind "I called preventDefault on wheel to stop the page scrolling and nothing happened." The browser did that on purpose, and the why — that it refuses to wait for your listener before it scrolls — is a thread that runs straight into the compositor and touch-action later in this guide. For now, just know the promise exists and that some of the time it is made for you.

Three verbs, then: one cancels what the browser would do, one stops the event travelling, one also silences the node's other listeners — and a fourth situation where cancelling was never on the table. With the event's trip and its stop buttons in hand, the next question is what the event is travelling inside of: the loop that ran your handler in the first place.

/ the loop

The loop your code runs inside

setTimeout(fn, 0) does not mean "run this now". It means "run this after everything currently queued". To feel the difference, four lines. What order do they print?

js · two logs, a timer, and a promise
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
chromium 151 · the actual order
1 4 3 2

4 beats 3, which is what most people expect — those two lines run straight through, top to bottom. But 3 beats 2, and both of those were "async". So "async" is not one queue. There are two, and they do not have equal standing.

Run to completion, then drain

Start with the part that is not surprising. 1 and 4 print in order because JavaScript runs your code to completion before it does anything else. It does not pause a running function to slip a timer in. So the two console.logs at the top level run start to finish, and the timer and the promise are both just scheduled on the way past — neither one interrupts the straight-line code.

The interesting part is what happens after that straight-line code finishes. The browser keeps two separate lines of waiting work, and they are not peers.

A task (older material calls it a "macrotask") is one full unit of work the loop picks up and runs to completion. A setTimeout callback is a task. A click handler is a task. The loop runs exactly one task, then looks around before picking up the next.

A microtask is smaller and more urgent. A resolved promise's .then callback is a microtask. So is anything you hand to queueMicrotask. The rule that produces 1 4 3 2 is this: after each task, the loop drains the entire microtask queue before it will start the next task. The HTML spec calls that draining a microtask checkpoint:

While the event loop's microtask queue is not empty: Let oldestMicrotask be the result of dequeuing from the event loop's microtask queue … Run oldestMicrotask.

Trace it. The top-level code is the current task. It prints 1, schedules a timer task, schedules a promise microtask, prints 4. That task is now done. The loop runs its microtask checkpoint: the promise callback prints 3. Only when the microtask queue is empty does the loop reach for the next task — the timer — and print 2. The promise did not beat the timer because it was faster or had a shorter delay. It beat it because a microtask drains before the next task even starts, and setTimeout(0) is a whole task away.

"After this, before anything else" has a name

This matters the moment you want to run something right after the current code but before the browser does anything else — before it paints, before any timer, before the next click. The instinct is to reach for setTimeout(fn, 0), and it is the wrong tool: a timer is a task, so it waits behind the render and behind every other task already queued. The right tool is a microtask.

js · two ways to defer; only one of them defers a little
setTimeout(() => reallyLater());     // a task: after paint, after other timers
queueMicrotask(() => rightAfter());  // a microtask: after this code, before the next task

queueMicrotask puts your callback in that same checkpoint the promise used, so it runs at the end of the current task and before anything else. await does the same thing under the hood — the code after an await is a microtask resuming. This is why frameworks batch their work into microtasks rather than timers: it is the shortest, most certain defer there is, and it happens before the user ever sees a frame.

There is one edge worth stating so it does not bite you: because the checkpoint drains the queue completely, a microtask that queues another microtask that queues another will keep the loop busy and hold off the next task — and the paint. Microtasks are for finishing the current unit of work, not for running an animation. That job belongs to a different slot in the loop entirely, and where that slot sits is the next chapter.

So "async" was never one bucket. Straight-line code runs to completion; then microtasks drain, all of them; then one task runs, and the cycle repeats. setTimeout(0) is the slowest of the three, not the fastest — a fact that reads backwards until you have seen 1 4 3 2 come out of a real browser.

One frame, in order

"I wrapped the update in requestAnimationFrame and it's still janky." This is the most common thing people believe about requestAnimationFrame: that it is a smoothing wrapper, and putting your work inside one makes the work smooth. It is not a wrapper. It is one specific step in a fixed sequence the browser runs every time it paints, and knowing where that step sits — and what runs before and after it — is the difference between fixing the jank and cargo-culting another rAF around it.

Watch the sequence directly. In one synchronous turn, schedule everything the browser can be asked to run near a frame: a timer, a requestAnimationFrame callback, a microtask, a promise, a DOM mutation an observer is watching, an element resize, and an element scrolling into view. Then log the order they actually fire.

chromium 151 · one turn schedules all of them; the order they run across the frame
   0 +53.70ms  taskA: start
   1 +56.50ms  taskA: end
   2 +56.50ms  queueMicrotask callback
   3 +56.50ms  promise.then callback
   4 +56.90ms  MutationObserver callback (microtask)
   5 +56.90ms  setTimeout(0) callback
   6 +56.90ms  rAF callback (ts=137.7)
   7 +57.10ms  microtask queued INSIDE rAF
   8 +57.10ms  ResizeObserver callback
   9 +57.40ms  IntersectionObserver callback

There is a lot in that list, so take it in three reads.

The microtasks drain first, then the frame begins

Lines 2 through 4 are the microtask checkpoint from the last chapter: queueMicrotask, the promise, and — this is the one people miss — the MutationObserver callback, which is also a microtask. All three drain at the end of the task, before any rendering work starts. Only after the queue is empty does the browser begin the work of building a frame.

That frame-building work has a name in the spec — the "update the rendering" steps — and it is a fixed, ordered list. Here it is, trimmed to the steps that fire something you wrote:

text · the browser's per-frame steps, in spec order
  ── update the rendering ─────────────────────────────
  1. run resize steps, scroll steps, evaluate media queries
  2. update animations and send events   → transitionend / animationend fire HERE
  3. run the animation frame callbacks    → requestAnimationFrame runs HERE
  4. recalc style + layout; broadcast ResizeObserver  (loop; else deliver loop error)
  5. run the intersection observation steps  → IntersectionObserver computed HERE
  6. mark paint timing
  7. paint

The measured order lines up with the spec order exactly: rAF (line 6 of the output) runs before ResizeObserver (line 8) runs before IntersectionObserver (line 9). Those are steps 3, 4 and 5 of the list, in order.

rAF is a step, not a wrapper

So requestAnimationFrame runs your callback at step 3 — after the browser has decided to build a frame, and just before it recalculates layout and paints. That is genuinely useful: it is your last chance to write to the DOM and have the change appear in this frame, and it hands you the frame's timestamp so animation is smooth. But it does nothing about how expensive your callback is. A slow layout inside a rAF is still a slow layout — the smoothness comes from what you do in the callback, not from the callback existing. Wrapping janky work in rAF moves the jank; it does not remove it.

Look at line 7. A microtask queued inside the rAF callback ran immediately after it — before the ResizeObserver at step 4 — not at the next task. That is the other half of the rule from the last chapter: the microtask checkpoint runs after every callback, not just at the end of the top-level task. Every time the loop hands control back from one of your callbacks, it drains microtasks before moving on.

Two orderings the clock can't show you, but the spec can

Two facts here matter and are too fast to read off a sub-millisecond log, so they come from the spec's ordering rather than the timestamps.

First, transitionend and animation-finished events fire at step 2 — before your rAF callback, not after. "Update animations and send events" comes before "run the animation frame callbacks". If a rAF reads state that a transitionend handler was going to set, the handler has already run this frame.

Second, ResizeObserver and IntersectionObserver are not the same slot, even though they sit one line apart in the output. ResizeObserver's callback is invoked directly at step 4, in-frame, before paint. IntersectionObserver's step 5 only computes the observations; the actual callback is queued as a separate task that runs after the frame has painted. That is the real reason one is described as "just before paint" and the other as "asynchronous". You will meet both properly a few chapters from now, and use them; for now note only that they land at different steps of the frame, and hold onto that — a whole chapter later leans on the gap.

Two things in that output are the machine's, not the rule, and it is worth not over-reading them.

Two numbers not to build on

The setTimeout(0) at line 5 landed just before the frame here, in every run, but that is timing, not spec. The HTML spec pins neither a timer task nor the render opportunity ahead of the other, so "setTimeout runs before/after paint" is not something to rely on. And the frame gaps here were about 8ms, because this headless browser runs near 120 frames a second with no screen to sync to. On a normal display they would be closer to 16. Think "one frame", not a fixed millisecond count: the order is spec, the clock is the machine.

That order is the spine of everything that follows. Measuring the page, running an observer, driving an animation — each is really a question about which step it happens on. With the frame's shape in hand, the next part is about reading the page's geometry without accidentally forcing the browser to build a frame early.

/ measuring

Four rulers that disagree

You want to know how wide an element is. There are four ways to ask, and on the same element they give four different numbers. This is not the browser being inconsistent — each ruler measures a different one of the boxes you met in the layout guide, and picking the wrong one is how a measurement comes out mysteriously off by a padding or a scrollbar.

Here is one element, deliberately built to make all four disagree: 200 by 100 of content, 20 of padding, a 10-pixel border, its overflow set to scroll, holding a child bigger than itself, and scaled to 1.5 on screen. Read its size four ways.

chromium 151 · content-box 200×100, padding 20, border 10, scale(1.5), overflowing child
  getBoundingClientRect: {"x":48, "y":40, "width":390, "height":240}
  offsetWidth/Height   : 260 / 160
  clientWidth/Height   : 240 / 140
  scrollWidth/Height   : 440 / 540

Four rulers, four answers. Walk them one at a time, because each number is exactly explainable and the explanation is the rule.

getBoundingClientRect: what's actually on screen

390. That is 260 × 1.5 — the border box, scaled. getBoundingClientRect() reports what the element looks like on screen, after transforms, in viewport coordinates. Its x: 48, y: 40 are where the box sits relative to the top-left of the viewport, and its width and height are the real painted size, transform included. It is also the only one of the four that returns subpixel floats — it will happily tell you 390.5. The spec ties it to the on-screen rectangles: it returns the smallest box containing all the element's client rectangles, which are post-transform.

Reach for it when you need to know where something is right now — to place a menu under a button, to test whether the pointer is inside a box, to do the measure step of an animation. It answers "where on the glass is this?"

offset, client, scroll: the layout box, pre-transform

The other three ignore the transform entirely. They report integer sizes of the element's layout boxes, as if the scale(1.5) were not there — which is often exactly what you want, because the transform is a visual effect and the layout is the truth underneath it.

offsetWidth is 260 — the border box: content 200 + padding 40 + border 20. It is the element's full laid-out width, the outermost edge you can see, rounded to an integer. The spec is explicit that it is measured "ignoring any transforms that apply to the element and its ancestors."

clientWidth is 240 — the padding box: content 200 + padding 40, with the border peeled off and any scrollbar subtracted. It is the size of the visible inner area, the region your content actually gets. Here the scrollbar subtracted 0, because this headless browser draws overlay scrollbars that float on top rather than taking width. On a desktop with classic scrollbars you would see roughly 15 pixels come off instead — the spec guarantees the scrollbar is excluded, but the amount depends on the operating system and the scrollbar style, so never hard-code it.

scrollWidth is 440 — the full content, including the part that overflows and is scrolled out of view: the 400-wide child plus the 40 of padding. It answers "how big would this need to be to show everything without a scrollbar?" Compare it to clientWidth and a larger value tells you the element is scrollable.

The one-line model

Strip it to a sentence you can carry. getBoundingClientRect is what's on screen — post-transform, subpixel, positioned in the viewport. offset, client and scroll are the layout box, pre-transform and integer: the border box, the padding box minus scrollbar, and the full scrollable content, in that order. When a measurement comes out wrong, the usual cause is asking the on-screen ruler for a layout number, or the layout ruler for an on-screen one — and now you know which is which.

There is a second, quieter surprise hiding in all four of these, and it is not about which box they measure. It is about what reading them costs. That is the next chapter, and it is the one that turns a slow page fast.

What a measurement costs

offsetWidth looks like reading a field. It is a property, you read it, you get a number — how expensive could that be? Here are two loops that do the same work on the same 2,000 elements. One reads a width and then writes a width, over and over. The other reads all 2,000 widths first, then writes all 2,000. Same reads, same writes, same total. Time them.

chromium 151 · 2,000 elements, read-then-write interleaved vs all reads then all writes
  interleaved (write->read x N): 1097.70 ms
  batched (all reads, all writes):  2.50 ms
  ratio: 439.1x

Same work, and one version is over four hundred times slower. Nothing about the elements changed. The only difference is the order of the reads and writes — and that order decides whether the browser lays the page out twice or two thousand times.

Why a getter runs a layout

Recall from the loop chapter that the browser normally does layout once per frame, at step 4, after all your code has run. It does not want to lay out more often than that — layout is the expensive part. So when you write to the DOM, the browser does not immediately recompute anything. It marks the layout as dirty and defers the actual work to the frame. Your write is cheap because the browser is procrastinating.

Then you read offsetWidth. That getter has to return a correct number, and the layout is dirty. The browser cannot hand you a stale answer, so it has no choice: it stops, runs a full layout synchronously, right now, and then returns the up-to-date width. Reading a geometry property on a dirty layout forces the browser to do the frame's work early. The name for that is a forced synchronous layout — or, when you cause it in a loop, layout thrashing.

Now the two loops make sense. The interleaved one writes a width (dirties the layout), then reads a width (forces a full layout to answer), then writes again (dirty), reads again (forced layout) — two thousand times, two thousand complete layouts. The batched one reads all 2,000 widths against a single clean layout, so those reads are cheap; then it writes all 2,000, dirtying the layout once, and the actual relayout is deferred to the frame where it happens exactly one time. Two thousand layouts versus one. That is the four-hundred-fold gap.

Mistake

Reading offsetWidth, clientWidth, getBoundingClientRect() — or getComputedStyle() — right after a style write, inside a loop, is the classic version of this. It is not the read that is slow and it is not the write. It is alternating them, because each read has to flush the write before it.

The fix is order, not restraint

The instinct on seeing a slow measurement is to measure less. That is the wrong lesson — the reads were never the problem. The fix is to batch all your reads, then all your writes, so the layout is forced at most once. Read every position you need up front, into variables; then do every DOM write. As long as no write sneaks in between the reads, the browser answers all of them from one clean layout.

js · the same work, reordered
// slow: each read flushes the write before it
for (const el of items) {
  el.style.width = el.offsetWidth + 10 + 'px';   // write, then read, then write...
}

// fast: one read pass, then one write pass
const widths = items.map(el => el.offsetWidth);   // all reads, one layout
items.forEach((el, i) => el.style.width = widths[i] + 10 + 'px');  // all writes, one dirty

The exact ratio is not a fixed number — it drifts with the machine and the work, and separate runs here landed anywhere from 400 to 1,300 times. What is fixed is the mechanism: an interleaved read forces a layout the batched version defers. This is the same forced synchronous layout you will meet again in a moment, wearing a completely different hat — there it is not a cost to avoid but a tool you reach for on purpose, to make a CSS transition run. One mechanism, two faces. First, though, the way out of measuring on a timer at all: observers.

Watchers, not timers

Somewhere in a lot of codebases is a setInterval that reads an element's size every 100 milliseconds to notice when it changed. It is wasteful when nothing moves, it is late when something does, and it forces a layout every time it fires. The browser has a better answer: three observers that call you back exactly when the thing you care about happens, and — the part worth internalising — each one fires at a different, defined point in the frame you saw laid out two chapters ago. They are not interchangeable, and their timing is the whole reason to pick one over another.

MutationObserver: the DOM changed (a microtask)

The first watches the tree itself — nodes added or removed, attributes changed. Schedule a setTimeout(0) first, then mutate the DOM, and watch which callback wins.

chromium 151 · a timer scheduled before the mutation; which fires first?
  synchronous code after mutating
  MutationObserver callback: childList,attributes (added 1 node)
  setTimeout(0) callback

The MutationObserver fired before the timer, even though the timer was scheduled earlier — because MutationObserver delivery is a microtask. It drains at the end of the current task, in that same checkpoint as promises, long before a timer task gets a turn. It hands you a batch of records describing everything that changed (childList and attributes here) in one call, rather than one call per change.

ResizeObserver: the box resized (in-frame, before paint)

The second watches an element's size. Resize a box and read what it reports.

chromium 151 · a box resized to 250×90, padding 8, border 4
  {"contentRect":  {"width":250, "height":90, "top":8, "left":8},
   "borderBoxSize": {"inline":274, "block":114},
   "contentBoxSize":{"inline":250, "block":90}}

contentBoxSize is the content box, 250 by 90. borderBoxSize.inline is 274 — that same 250 plus 2×8 padding plus 2×4 border. The contentRect.top and .left of 8 are the padding, not a position. Two things about it are worth keeping. Prefer borderBoxSize and contentBoxSize over contentRect: the spec flags contentRect as a legacy shape kept only for compatibility. And it fires at step 4 of the frame — after your rAF callbacks, after style and layout are recomputed, but before paint. That placement is the point: you can read the new size and write a response, and the browser folds your write into the very same frame's paint. No flash of the wrong size.

IntersectionObserver: it entered the viewport (a task, after paint)

The third watches whether an element overlaps another — usually, whether it has scrolled into view. Scroll a 100-pixel target through a 200-pixel window and log each crossing.

chromium 151 · a target crossing a scroll container, thresholds [0, 0.5, 1]
  records length immediately after observe(): 0
  {"isIntersecting":false, "ratio":0,   "scrollTop":0}
  {"isIntersecting":true,  "ratio":1,   "scrollTop":350}
  {"isIntersecting":true,  "ratio":0.5, "scrollTop":450}

isIntersecting flips as the target enters; intersectionRatio is the visible fraction — 1 when fully inside, 0.5 when half. The threshold array is the set of ratios you want to be told about. But look at the first line: zero records immediately after observe(). It does not call you synchronously. The observations are computed at step 5 of the frame, but the callback is delivered from a separate task that runs after the frame has painted. That is why it is the right tool for lazy-loading images or firing analytics when something scrolls into view — it never touches the scroll handler, and being a frame late costs nothing for those jobs. It would be the wrong tool if you needed the size in the same frame you changed it; that is ResizeObserver's job.

Three watchers, three slots

text · the same frame, three observers, three points
  MutationObserver     microtask, end of the current task     (before the frame)
  ResizeObserver       step 4, after rAF, before paint        (in the frame)
  IntersectionObserver a queued task, after paint             (after the frame)

Reading a size in an IntersectionObserver callback and expecting it to match one read in a ResizeObserver callback from the "same" change is reading them a frame apart. The observers feel alike — you get a callback with entries — but their timing is exactly what distinguishes them.

The loop error that is not a bug

One message scares people out of ResizeObserver: ResizeObserver loop completed with undelivered notifications. It shows up red, with no stack you wrote, so it reads as somebody else's bug to silence. It is neither. Write a callback that resizes the very element it is observing, and reproduce it:

chromium 151 · a callback that resizes the element it observes
  window "error" messages: ["ResizeObserver loop completed with undelivered notifications." x3]
  page-level errors seen by Playwright: []

First, notice how it arrives: as a window error event, not a thrown exception and not console.error, so the test harness's normal error hooks caught nothing and only an addEventListener('error', …) saw it. Second, what it means. Step 4 broadcasts resize observations in a loop. If your callback resizes an observed element, that produces a new size the loop cannot settle in this frame, so the browser defers the leftover to the next frame and emits this message to tell you it happened. It is a feedback-loop warning, not a crash. Benign for a single settling step; a real problem if your callback resizes unconditionally every time, because then it repeats forever. The fix is to break the loop — only resize when the size actually changed — not to swallow the message.

Three watchers, then, each pinned to a different point in the frame, replacing every geometry setInterval you have ever written. With the page measured, the last piece before the visual machine is making something move — and that starts with the one callback the browser runs right before it paints.

/ motion

Making things move

There are three ways to animate something in a browser, and each one teaches a piece of the loop you just learned. Start with the most manual: run your own code every frame.

requestAnimationFrame, and the timestamp it hands you

You met rAF as step 3 of the frame — the callback that runs just before layout and paint. To animate, you schedule the next one from inside the current one, so your code runs once per frame in step. The browser hands each callback a timestamp, and that timestamp is worth understanding, because it is not what you would guess.

chromium 151 · timestamps across four frames, and two callbacks scheduled in one turn
  timestamps across 4 frames: [59.5, 73.7, 81.6, 90.1]  (monotonic: true)
  two rAF scheduled in ONE turn share a timestamp: true  [59.5, 59.5]

The timestamp is the frame's start time, not the clock reading at the moment your callback runs. Two callbacks scheduled in the same turn get the identical value — 59.5 for both — because they belong to the same frame, and the browser stamps them with when the frame began, not when it reached each one. Drive your animation off this number rather than a counter, and every element you move this frame agrees on what "now" is, so nothing drifts. The frame gaps here are about 8 to 14 milliseconds because this headless browser has no display to sync to; on a 60Hz screen they would be near 16. Write your animation in terms of elapsed time, never a fixed per-frame step, and it runs correctly at any refresh rate.

That is the low-level tool. Most of the time you do not want to run code every frame — you want to describe the start and end and let the browser interpolate. That is what CSS transitions are for, and they have one trap that catches everyone once.

The transition that jumps

Insert an element at opacity: 0, give it transition: opacity 100ms, then set opacity: 1 to reveal it. It should fade in. Sample its opacity over the next 130 milliseconds and see whether it does.

chromium 151 · reveal a just-inserted element — without, then with, a forced reflow between
  BROKEN  (set opacity 1 in the same turn as insertion):
          opacity samples: [1,1,1,1,1,1,1,1,1,1,1,1,1,1]
          transitionend: null

  WORKING (force a reflow between insertion and opacity 1):
          opacity samples: [0,0.08,0.17,0.25,0.33,0.41,0.5,0.58,0.67,0.75,0.83,0.92,1,1]
          transitionend: {"elapsedTime":0.1, "propertyName":"opacity"}

The broken version jumps straight to 1 and never animates — transitionend never even fires. The reason is a rule you already have from the loop: a transition animates from the last committed style to the new one, and the browser only commits styles once per frame. When you insert at opacity: 0 and set opacity: 1 in the same turn, the 0 is never committed as a rendered style. By the time the frame computes styles, the element's opacity is just 1, full stop — there is no earlier value to interpolate from, so there is nothing to animate.

The fix is one line, and it is a mechanism you already met wearing the other hat:

js · the one line that makes the fade run
el.style.opacity = '0';   // insert hidden
void el.offsetWidth;      // force a synchronous layout — commit the 0 now
el.style.opacity = '1';   // now this is a real change to transition from

void el.offsetWidth is a forced synchronous layout — a reflow, the same forced layout from the last chapter — the exact thing you were told to avoid in a loop. Here you want it, once, on purpose. Reading offsetWidth forces the browser to flush the pending opacity: 0 into a committed style right now, so the following opacity: 1 is a genuine change the transition can run. Same mechanism as layout thrashing; opposite intent. The working run proves it: opacity ramps cleanly from 0 to 1 and transitionend fires with elapsedTime: 0.1.

Web Animations: name both ends, skip the trick

The reflow trick works, but it is a trick — you are exploiting a side effect to commit a style. The Web Animations API sidesteps it by letting you name both endpoints explicitly, so there is no "committed start" to worry about. Call element.animate() with keyframes and a duration.

chromium 151 · element.animate() returns a controllable Animation object
  {"states": {"atStart":"running", "afterFinished":"finished"},
   "isAnimationInstance": true, "ctorName": "Animation"}

element.animate(keyframes, options) returns an Animation object. It starts in running and moves to finished, and it hands you a .finished promise that resolves when it gets there — so you can await an animation and do something after. Because the keyframes state both the start and the end, there is no same-turn problem to work around and no offsetWidth to poke. It is also the API a rAF loop grows into once you stop wanting to hand-write the interpolation.

Three tools, one loop underneath. rAF runs your code at the frame; a CSS transition interpolates from the committed style, so it needs a start it has actually seen; Web Animations names both ends and needs no trick. The next chapter uses all three at once to animate something CSS flatly refuses to animate on its own — a change in layout.

FLIP: measure, invert, play

Some layout changes the browser will not animate for you. Move a box from the left of a flex row to the right — by changing justify-content, or moving it to a new parent — and it teleports. There is no transition you can write on justify-content that tweens the box across, because the two positions are two different layouts, and layout is not an interpolatable value. The box was at the left; now it is at the right; there is no "halfway" the browser knows how to compute.

FLIP is the technique that animates it anyway, and it is the front half of this guide in a single move: it measures two layouts, computes an inverse transform, and plays that transform on the compositor. The name spells out four steps — First, Last, Invert, Play — and the first two are both measurements, which is why the short version is measure, invert, play.

The four steps, measured

Take an 80-pixel box in a 600-pixel flex row, and move it from flex-start to flex-end.

chromium 151 · an 80px box moved from the left of a flex row to the right
  First : {"left":8,   "top":8}     ← measure where it is now
  Last  : {"left":528, "top":8}     ← change the layout, measure where it landed
  invert transform (dx,dy): {"dx":-520, "dy":0}
  Invert (after applying transform): {"left":8, "top":8}   ← pinned back to First
  Invert rect === First rect: true
  Final (after play, transform cleared): {"left":528, "top":8}

Read it as the four steps.

First. Before you touch anything, measure where the box is with getBoundingClientRect(). It is at left: 8. This is the on-screen ruler from the measuring chapter, used for exactly what it is for.

Last. Now make the change — set justify-content: flex-end — and measure again immediately, in the same turn. The box has already moved in layout to left: 528. The browser has not painted yet, so on screen nothing has visibly happened, but the geometry is already the new one.

Invert. Compute the transform that would put the box back where it started: dx = First.left − Last.left = 8 − 528 = −520. Apply transform: translate(-520px, 0). The measured result is the payoff line: after the transform, the box's rectangle is left: 8 again, and Invert rect === First rect is true. The box now lives in its new slot at 528 but is painted at its old spot at 8. Visually it has not moved at all, even though its layout has completely changed.

painted at 8 real slot · 528 Invert: translate(−520, 0)
The box lives in its new slot on the right, but the inverse translate paints it back on the old slot. The reader sees no jump — yet.

Play. Now animate the transform from translate(-520, 0) back to translate(0, 0). As it releases, the box glides from its old painted position to its real layout slot, and the last measured line confirms it ends at left: 528 with the transform cleared. Use a CSS transition or element.animate() for this step — either works, because the two endpoints of a transform are interpolatable, even though the two layouts were not.

Why this is smooth, and why it's the bridge

The trick that makes FLIP feel free is that the Play step animates a transform, and a transform is one of the two properties the compositor can move without redoing layout or paint each frame. You measured a layout change, but you are animating a transform, so the per-frame cost is near nothing. That is why the box glides at full frame rate where animating left from 8 to 528 would recompute layout on every single frame.

That last claim — that a transform is cheap where left is not — is not something to take on faith, and it is not re-measured here. It is the compositor's job, and it is measured directly in the next part. FLIP is the seam: everything up to here has been the front of the machine — events, the loop, measurement, timing — and FLIP has just quietly used two things from the back of it: a transform as a coordinate change, and the compositor that moves it for free. The rest of the guide is those two, properly. We start with the transform.

/ the visual machine

Transforms: a second coordinate system

FLIP leaned on a transform to pin a box in place. Now use it properly, because transform is not "move a box" — it is a second coordinate system laid over the box's real one, and two facts about it explain almost every surprise it produces.

The first fact is the one that made FLIP possible at all: a transform does not change layout. When you translate or scale an element, its siblings do not move, its own laid-out size does not change, and the space it occupies in flow stays exactly where it was. The transform is applied after the browser has sized and positioned everything — it changes only how the element is painted and where its descendants are anchored. That is why the box in FLIP could be visually pinned at the left while genuinely living in its slot on the right: layout said 528, the transform painted it at 8, and no other element on the page noticed.

It composes left to right

The second fact is that a transform is a list of operations, and the list composes left to right as successive changes to the coordinate system — so the order is not just cosmetic. Same two functions, opposite order, and the box lands 100 pixels apart.

chromium 151 · the same box, translate-then-scale vs scale-then-translate (origin 0 0)
  translate(100px,0) scale(2)   matrix(2, 0, 0, 2, 100, 0)   rect x=200
  scale(2) translate(100px,0)   matrix(2, 0, 0, 2, 200, 0)   rect x=300

The browser collapses each list into one matrix(...); you will read those six numbers properly in a later chapter, but for now only the fifth matters — the horizontal shift, 100 in the first row and 200 in the second. translate(100px) scale(2) moves the box 100 to the right, then scales — the box ends with its translation at 100. scale(2) translate(100px) scales the coordinate system first, so the later translate(100px) is a 100-unit move in a system where every unit is now doubled — a 200-pixel move. The final rectangle sits at 200 versus 300 on screen. The way to read a transform list is left to right, each operation acting inside the coordinate system the previous ones have already set up. Scale first and everything after it, including translations, is scaled too.

transform-origin moves the pivot, not the matrix

Every transform happens around a pivot — the transform-origin, which defaults to the element's centre. Change it, and the result moves even though the transform itself is identical. Rotate a box 45 degrees around its top-left corner, then around its centre:

chromium 151 · rotate(45deg), origin at the corner vs the centre
  rotate(45deg) origin "0 0"     matrix(0.707107, 0.707107, -0.707107, 0.707107, 0, 0)   rect x=64.6 y=100.0
  rotate(45deg) origin "center"  matrix(0.707107, 0.707107, -0.707107, 0.707107, 0, 0)   rect x=97.0 y=72.0

The computed matrix is the same in both — transform-origin does not appear in it. It is applied around the matrix: shift the pivot to the origin, apply the transform, shift back. That is why the matrix is identical and the on-screen rectangle is not — x=64.6 versus x=97. If a rotation or scale swings an element somewhere unexpected, the transform is usually right and the origin is the thing to check.

The individual properties, and the gotcha

There is a newer way to write the same operations: the standalone translate, rotate and scale properties, instead of stuffing everything into one transform string. They buy you one real thing — they apply in a fixed order regardless of how you write them, so you never have to argue about composition order again.

chromium 151 · individual properties: declaration order does not matter
  translate:100px 0; scale:2   computed transform="none"   rect x=200 w=200
  scale:2; translate:100px 0   computed transform="none"   rect x=200 w=200

Both orderings give x=200. The browser always applies translate, then rotate, then scale, then any transform list — so swapping the two declarations changes nothing. That is the feature: composable pieces you can set independently (animate the scale without touching the translate) without the left-to-right ordering trap.

But read the middle column. getComputedStyle(el).transform is "none" for both. The individual properties do not fold into the transform value. So any code that reads .transform to detect whether an element is transformed will completely miss an element moved with translate: — and you have libraries doing exactly that read. If you set the individual properties, know that transform still reports none.

That is the whole model: a transform is a coordinate system applied after layout, composing left to right around a pivot, with a newer per-property form that fixes the order for you. Layout does not feel it and siblings do not move. What does change is how the element is painted — and painting cheaply, frame after frame, is a different machine underneath. That machine is the compositor, and it is the reason FLIP was smooth.

The compositor: what moves cheaply

FLIP animated a transform and it glided. Animate the same move as a change to left and it stutters. Both change where the box appears; only one is cheap. The reason is a second thread you never call directly — the compositor — and which properties it can move on its own.

Here is the model. To paint the page, the main thread does layout (where every box goes) and paint (filling in pixels), and it can rasterise parts of the page into separate layers — think of them as transparency sheets stacked on a light table. Once those layers exist, a different thread, the compositor, can shift them around, fade them, and stack them to make each frame — without waking the main thread at all. Moving a finished sheet is cheap. Re-drawing what is on it is not.

That split is the whole performance story. Some property changes only need the compositor to reposition an existing sheet. Others force the main thread to redo layout or paint, and then hand a fresh sheet over. Which is which is not a matter of taste, and you can read the browser's own reasons for promoting an element to its own layer.

The two properties with a promotion reason of their own

Take six boxes. Two get will-change hints, four animate a different property each — transform, left, opacity, width — and ask the browser, through its debugging protocol, why each element got its own layer.

chromium 151 · compositing reason per element, read from the browser's layer tree
  will-change: transform   →  ["WillChangeTransform"]
  will-change: left        →  ["WillChangeOther"]
  animating transform      →  ["ActiveTransformAnimation"]
  animating left           →  ["Overlap"]
  animating opacity        →  ["ActiveOpacityAnimation"]
  animating width          →  ["Overlap"]
  (a plain, un-animated box got no layer at all — painted into the shared one)

Read the four animations. Animating transform gets a layer for the reason ActiveTransformAnimation; animating opacity gets one for ActiveOpacityAnimation. Those are dedicated reasons: the compositor knows how to change a layer's position and its alpha by itself, so it promotes the element to its own sheet and runs the whole animation off the main thread. Animating left and animating width get a layer only for the reason Overlap — an incidental promotion because they happen to sit over a composited neighbour, not because the compositor can animate them. It cannot. A change to left or width alters layout, which only the main thread can compute, so every frame of that animation goes back through layout and paint.

So "animate transform and opacity" is not a style preference. They are the two properties the compositor has a promotion reason for — the two it can move without the main thread. That is exactly why FLIP, which animates a transform, is smooth, and why the left-animated version of the same move is not. (These reason strings are Chromium's own internal names, not a web standard — the conclusion is portable across browsers; the exact words are not.)

will-change is a request, and it is not free

The two will-change rows show it working as a promotion request. will-change: transform produced its own layer with no animation even running. It tells the browser "I am about to animate this, promote it now" so the first frame is not spent rasterising a new layer. But notice will-change: left also got a layer, WillChangeOther — even though the compositor cannot animate left. will-change is honoured eagerly, whether or not the promotion helps.

That eagerness is the cost. Each promoted layer holds memory for as long as the hint is set, animating or not — so a will-change left on hundreds of elements is hundreds of layers sitting idle. And it has two side effects that have nothing to do with performance: will-change: transform makes the element a containing block for positioned descendants and a stacking context, the same two things a real transform does. So adding it can silently re-anchor a position: fixed child, or trap a z-index — the stacking-context trap from the layout guide, triggered by a property you added for speed. Set it just before you animate, on the few elements that need it, and clear it when you are done.

The thread that closes the passive thread

One loose end from the events chapter ties off here. Passive listeners existed because the browser "must not wait for your wheel handler before it scrolls." Now you can see why it cannot wait: scrolling is a compositor job, run on that separate thread, and the compositor is not allowed to block on your main-thread JavaScript. If it had to pause every scroll until a wheel listener decided whether to call preventDefault, scrolling would stutter on every page with such a listener. So the browser assumes the listener is passive and scrolls immediately on the compositor thread. That is the same off-main-thread machine, seen from the input side — and it is the reason the next chapter's drag needs a CSS property, not a preventDefault, to work on a phone.

Pointer events: one model for every input

You build a drag. On your laptop it works. You test it on a phone and it fights you — the page scrolls instead of dragging, or the drag gives up the moment your finger moves fast. Both failures are real, both are common, and neither is fixed by trying harder with the events you already have. They are fixed by two specific things: pointer capture, and one CSS property. This chapter is those two, on top of one input model that covers mouse, touch and pen at once.

That single model is pointer events. Instead of writing mousedown/mousemove for the mouse and touchstart/touchmove for touch and reconciling the two, you write pointerdown/pointermove/pointerup once. A mouse, a finger and a stylus all arrive as pointers with the same event shape; a pointerType field tells them apart if you care. One code path, every input.

The drag that dies when the mouse leaves

Here is the first failure, the laptop one. Put a drag handler on a small element: on pointerdown you start dragging, on pointermove you follow the pointer. Drag slowly and it works. Drag fast, so the pointer outruns the element and leaves it, and the drag dies. Watch the events, with and without one line.

chromium 151 · a mouse drag from inside a 40px box out to (400, 400)
WITH setPointerCapture:
  down on thumb, gotpointercapture,
  move target=thumb, move target=thumb, move target=thumb, move target=thumb,
  up, lostpointercapture

WITHOUT setPointerCapture:
  down on thumb,
  doc move target=BODY, doc move target=BODY, doc move target=BODY, doc move target=BODY
  (no more moves on the box, and no 'up' ever reaches it)

Without capture, once the pointer leaves the box the pointermove events retarget to whatever is now under the pointer — the BODY — and the box stops hearing them. Worse, the pointerup never reaches the box either, so your drag never gets a clean end. This is because events go to whatever the pointer is over, and mid-drag the pointer is over the page, not your handle.

element.setPointerCapture(pointerId), called on pointerdown, fixes it in one line. With capture, every pointermove still reports target = thumb even from 400 pixels away, and gotpointercapture/lostpointercapture bracket the whole drag. Capture retargets the pointer's events to the element you chose, regardless of what the pointer is physically over. The spec describes exactly the slider case this is for — sliding "back and forth even if the pointer slides off of the thumb." Any drag you write needs it.

There is a wrinkle worth knowing: on touch you get this capture for free. The spec says touch and stylus "SHOULD behave exactly as if setPointerCapture was called" on pointerdown. So a drag can work on a phone and still be broken on a laptop — the mouse is the case that needs the explicit call, and the laptop is where you test.

The drag that dies on a phone

Now the second failure. Your drag works on the laptop, capture and all. On a phone, dragging vertically scrolls the page instead. You reach for preventDefault in the pointermove handler to stop the scroll — and it does nothing, for the reason the compositor chapter just gave you. The fix is a CSS property. Emulate a touchscreen and swipe vertically over an element at three touch-action settings:

chromium 151 · a vertical swipe over an element, three touch-action values
  touch-action: auto   →  pointerdown, pointermove, POINTERCANCEL
  touch-action: none   →  pointerdown, pointermove ×6, pointerup
  touch-action: pan-y  →  pointerdown, pointermove, POINTERCANCEL

With the default auto, the browser sees a vertical drag, decides it is a scroll, takes the gesture for itself, and fires pointercancel — your drag is over after one move. touch-action: none is the one that keeps the gesture: six pointermoves and a clean pointerup, the whole drag delivered to you. And pan-y still cancels a vertical drag, because pan-y means "the user may pan vertically" — you handed the vertical gesture back to the browser.

The reason preventDefault cannot save this is normative. Panning and zooming are deliberately not a cancelable default action of a pointer event — the spec says they "cannot be suppressed by canceling a pointer event," and that authors "must instead use touch-action." This is the passive-listener thread from the events chapter, fully closed: the browser will not wait for your JavaScript to decide, so the only way to claim a gesture is to declare it ahead of time in CSS. touch-action: none on your drag surface is not a polish item; it is a prerequisite, and a drag written without it is a non-functional drag on touch.

Hit-testing through a transform is free

One more thing pointer events give you, and it is the quiet foundation of the whole capstone. When an element sits inside a CSS transform — scaled, panned — the browser still hit-tests it correctly. A click at the spot where a scaled element appears lands on that element; :hover, focus and elementFromPoint all work in transformed space with no inverse maths from you. You do not convert screen coordinates back into the element's space to figure out what was clicked — the browser does it, transform included. Hold that thought: it is why the pannable, zoomable canvas you are about to build can be ordinary DOM instead of a hand-hit-tested drawing surface. Pointer capture to hold a drag, touch-action: none to keep the gesture, free hit-testing through the transform — those three are the drag half of the canvas. The other half is drawing the connections between nodes, and for that you need just enough SVG.

SVG coordinates, just enough

You draw a circle in an SVG and it comes out an ellipse. You did not ask for an ellipse. This is the one SVG concept you need for the capstone, and the squashed circle is the fastest way to see it: viewBox is not a crop or a zoom level — it is a mapping from the coordinate system you draw in to the pixels the SVG occupies, and when that mapping is not uniform, your circle stretches.

Draw the same <circle cx="50" cy="50" r="10"> into a 400-by-200 SVG four ways, and measure what lands on screen.

chromium 151 · one circle, four viewBox settings, in a 400×200 SVG
  viewBox="0 0 100 100" (default)         →  w=40 h=40   round
  viewBox="0 0 100 100" aspect "none"     →  w=80 h=40   an ellipse
  viewBox="0 0 100 100" align xMinYMid    →  w=40 h=40   round, shifted left
  no viewBox                              →  w=20 h=20   1 unit = 1 px

viewBox is a mapping

viewBox="0 0 100 100" says "the drawing coordinates run 0 to 100 in each direction; map that region onto my box." The box is 400 by 200. Horizontally that is a scale of 4, vertically a scale of 2. By default SVG keeps the mapping uniform — it picks the smaller scale, 2, so shapes keep their proportions — and the r=10 circle becomes a 40-pixel circle. Set preserveAspectRatio="none" and you drop the uniformity: now it scales x by 4 and y by 2 independently, and the circle is drawn 80 wide by 40 tall. That ellipse is the whole lesson. The circle never changed; the mapping did.

The align value (xMinYMid and friends) only decides where the leftover space goes when the mapping is uniform — same 40-pixel circle, just parked at the left instead of centred. And with no viewBox at all, there is no mapping: one user unit is one CSS pixel, so r=10 draws a 20-pixel circle. That last case matters more than it looks, and you will use it in the capstone.

getScreenCTM: the mapping as a matrix you can invert

The reason you care about the mapping is that you constantly need to cross it. A pointer event gives you a screen coordinate; your shapes live in user coordinates. To answer "what user point did the user click?" you need to run the mapping backwards. SVG hands you the mapping as a matrix.

chromium 151 · the first SVG's screen matrix, and a screen point converted back to user space
  getScreenCTM(): {a:2, b:0, c:0, d:2, e:100, f:0}
  screen (200, 100)  --getScreenCTM().inverse()-->  user (50, 50)

getScreenCTM() returns the current transform from user space to screen space — here scale 2 (a and d), shifted by 100 across (e) from centring the drawing in the wider box. Invert it and you have screen-to-user. Feed the circle's measured on-screen centre, (200, 100), through the inverse and you get back exactly (50, 50) — the cx, cy you drew it at. The recipe is one line:

js · screen coordinate to user coordinate
const userPt = new DOMPoint(clientX, clientY)
  .matrixTransform(svg.getScreenCTM().inverse());

The shortcut the capstone takes

Here is the payoff, and it is the "no viewBox" case doing something clever. If you put an SVG with no viewBox inside an element that already carries a CSS transform, the SVG's own mapping is 1:1 — one user unit is one pixel — and the CSS transform does all the scaling and panning for free. You draw an edge at its plain coordinates, the transform on the ancestor moves and scales it along with everything else, and you never write a coordinate conversion. That is exactly how the connections in the canvas will be drawn, and — you will see in two chapters — exactly how React Flow draws them too. You now have every piece: events, the loop, measurement, motion, transforms, the compositor, pointers, and just enough SVG. Time to assemble them.

/ build it

Build: a canvas you can pan and zoom

This is the thing you came for. A surface with nodes you can see, an edge connecting them, that you can pan by dragging, zoom toward your cursor with the wheel, and that works on a phone — built from the primitives in this guide, no library. Here is the whole structure first, then the two pieces of logic, then the measurements that prove each one.

The structure: one transformed viewport

Everything visible lives inside a single <div> — the viewport — and that div carries one CSS transform: a translate for the pan and a scale for the zoom. The nodes are absolutely positioned divs placed in flow coordinates (the drawing's own coordinate system, before any pan or zoom). The edge is an SVG with no viewBox, drawn in those same flow coordinates. Move the viewport's transform and the whole scene — nodes, edge, all of it — moves together, for free.

html · a scene, one transformed viewport, two nodes and an edge inside it
<div id="scene">                              <!-- the fixed window; touch-action: none -->
  <div id="viewport">                         <!-- the one transformed div -->
    <svg id="edges"><path id="e1"></path></svg> <!-- no viewBox: 1 unit = 1 flow unit -->
    <div class="node" style="left:0px;   top:0px;">Start</div>
    <div class="node" style="left:260px; top:160px;">Next</div>
  </div>
</div>
css · the lines that make it a viewport
#scene    { position: absolute; inset: 0; overflow: hidden; touch-action: none; }
#viewport { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
#edges    { position: absolute; overflow: visible; pointer-events: none; }

Three of those lines do quiet, disproportionate work. touch-action: none on the scene is the pointer chapter's prerequisite, so a drag on a phone stays a drag instead of scrolling the page. transform-origin: 0 0 on the viewport makes the scale grow from the top-left corner rather than the centre, which keeps the zoom maths simple. And overflow: visible on the SVG lets the edge draw outside the SVG's own zero-ish box, while its missing viewBox means one SVG unit equals one flow unit, so the CSS transform does all the scaling, exactly as the last chapter set up.

The transform is held in three numbers — tx, ty, z — and applied with one line, the same shape you will meet again in React Flow's source:

js · the entire render step
let tx = 40, ty = 30, z = 1;
function apply() {
  viewport.style.transform = `translate(${tx}px, ${ty}px) scale(${z})`;
}

That is the whole scene. A node written at flow (0, 0) should therefore appear on screen at the translate, (40, 30). Measure it, and check that hit-testing sees it there:

chromium 151 · initial transform, and a hit-test at the node's on-screen centre
  viewport inline transform: translate(40px, 30px) scale(1)
  node "Start" (flow 0,0) renders on screen at: (40, 30)
  elementFromPoint at its on-screen centre → "n1"

The node lands at (40, 30) — exactly the translate — and elementFromPoint at that spot returns the node. That second line is the free hit-testing from the pointer chapter: the browser found the node through the transform with no inverse maths from us. That single fact is why this can be plain DOM.

Panning: a drag on the whole surface

Pan is a drag applied to the viewport's translate. On pointerdown, capture the pointer (so the drag survives leaving any element) and remember where it started. On each pointermove, add the pointer's movement straight to tx and ty.

js · pan = pointer capture + add the delta to the translate
scene.addEventListener('pointerdown', (e) => {
  dragging = true; lastX = e.clientX; lastY = e.clientY;
  scene.setPointerCapture(e.pointerId);           // hold the drag off-element
});
scene.addEventListener('pointermove', (e) => {
  if (!dragging) return;
  tx += e.clientX - lastX;                         // 1:1 with screen pixels
  ty += e.clientY - lastY;
  lastX = e.clientX; lastY = e.clientY;
  apply();
});
scene.addEventListener('pointerup', (e) => {
  dragging = false; scene.releasePointerCapture(e.pointerId);
});

Drag the pointer 60 across and 40 down, and the translate should move by exactly that:

chromium 151 · drag the pointer by (60, 40)
  before: translate(40px, 30px) scale(1)
  after : translate(100px, 70px) scale(1)

40 → 100, 30 → 70 — the translate moved by the drag, 1:1 with screen pixels. And it is 1:1 regardless of zoom, because the code adds the screen-space delta directly. Panning at zoom 1.2, a 50-pixel drag still moved tx by exactly 50. Pan is the easy half; you add screen pixels to a translate.

Zooming toward the cursor

Zoom is the half with real maths, and it is worth deriving because getting it wrong is what makes a canvas feel like it is fighting you. The requirement: when you wheel over a point, the flow point under the cursor should stay under the cursor as the zoom changes. Zoom toward where you are pointing, not toward the corner.

scene · zoom 1.4 scene · zoom 1 cursor — stays put
Wheeling zooms about the cursor: the flow point under it stays pinned, and the whole scene scales around that one fixed point — its corners moving outward by the zoom factor.

Two facts give you the formula. The transform maps a flow point to screen as screen = flow · z + t. Turn that around: the flow point currently under a screen position is flow = (screen − t) / z. That is the point you want to keep still. After the zoom changes the scale z to a new value z' — the prime just means "the value after the zoom" — you need a new translate t' that keeps that same flow point at that same screen position: t' = screen − flow · z'. Two lines of arithmetic, done for x and y.

js · zoom about the cursor — the whole handler
scene.addEventListener('wheel', (e) => {
  e.preventDefault();                               // works: this listener is non-passive
  const r = scene.getBoundingClientRect();
  const sx = e.clientX - r.left, sy = e.clientY - r.top;   // cursor, scene-local
  const fx = (sx - tx) / z,      fy = (sy - ty) / z;       // flow point under it
  const zNew = Math.min(4, Math.max(0.2, z * (e.deltaY < 0 ? 1.2 : 1/1.2)));
  tx = sx - fx * zNew;                              // t' = screen − flow·z'
  ty = sy - fy * zNew;
  z = zNew;
  apply();
}, { passive: false });

Note the { passive: false }. A wheel listener is passive by default, and preventDefault inside a passive listener is ignored — the events chapter's bug. To stop the page scrolling while you zoom the canvas, you must register the listener as explicitly non-passive. Now wheel in over cursor position (300, 250) and check the numbers against the formula, twice.

chromium 151 · two wheel-ins over the same cursor, measured against the formula
  zoom 1 → 1.2:  flow under cursor (200, 180)
                 measured  translate(60px, 34px)   scale(1.2)
                 predicted translate(60px, 34px)   scale(1.2)

  zoom 1.2 → 1.44:  flow under cursor (200, 180)
                    measured  translate(12px, -9.2px)  scale(1.44)
                    predicted translate(12px, -9.2px)  scale(1.44)

Every number the formula predicts is the number the browser produced, at both zoom levels. And the line that proves the whole point: the flow point under the cursor is (200, 180) before the first zoom and still (200, 180) after the second. That is what "zoom toward the cursor" means, made exact — the same spot in the drawing stays pinned under the pointer no matter how far you zoom.

That is a working pannable, zoomable canvas, and every part of it is something you measured earlier in this guide: one transformed div, absolutely positioned nodes, an SVG edge with no viewBox, pointer capture for the drag, touch-action: none for the phone, a non-passive wheel listener, and free hit-testing through the transform. You did not draw a single pixel yourself; the browser laid it all out. Which raises a fair question about the real thing, the library on your own roadmap surface. Surely React Flow, an infinite graph you can pan forever, is doing something cleverer than this. Open its source and find out.

It was React Flow all along

An infinite, pannable node graph — surely that is a <canvas>. Every desktop equivalent is an immediate-mode drawing surface, and "node editor" reads like something you paint. So the reasonable guess is that React Flow, the graph on a real roadmap surface, does something categorically cleverer than the div you just built. Open its source and see. This is @xyflow/react version 12.11.2, the version pinned in a shipping application, read straight off disk.

The viewport is one transformed div

Start where pan and zoom land. The whole scene sits in a component called Viewport, and it renders exactly one div. Here is how it computes that div's transform — the function is three lines from the top of the file:

js · @xyflow/react 12.11.2, dist/esm/index.js — the transform string
const toTransformString = (transform) =>
  `translate(${transform[0]}px,${transform[1]}px) scale(${transform[2]})`;

That is your apply() function, to the character: a translate for the pan, a scale for the zoom, from a three-number transform. The Viewport component does one more thing worth seeing, and its own comments explain it:

js · the same file — writing the transform to the DOM, bypassing React
// transform changes every pan/zoom frame, so write it to the DOM
// directly to keep React out of the hot path
useIsomorphicLayoutEffect(() => {
  const applyTransform = () => {
    const transform = store.getState().transform;
    // ...only touch the DOM when x, y or zoom actually changed
    viewportRef.current.style.transform = toTransformString(transform);
  };
  applyTransform();
  return store.subscribe(applyTransform);
}, [store]);

Read the comment. On every pan or zoom frame, React Flow writes style.transform directly to the DOM element, deliberately not going through React's render at all — "to keep React out of the hot path." Your capstone did the same thing without needing to be told: viewport.style.transform = ... in a plain event handler. React Flow arrived at the identical move because it is the right one — you do not want a component re-render on every frame of a drag. The framework steps out of the way and writes the same one line you wrote.

Nodes are divs; edges are SVG with no viewBox

The nodes are absolutely positioned divs, each carrying its own translate in flow coordinates:

js · the same file — a node's transform
transform: `translate(${internals.positionAbsolute.x}px,${internals.positionAbsolute.y}px)`

And each edge is its own <svg> element — one SVG per edge — containing a <path>, rendered with no viewBox. Its stylesheet supplies the three lines you now recognise as essential: transform-origin: 0 0 on the viewport, overflow: visible on the edge SVGs, and touch-action: none on the pane that catches the drag. The default node is 150 wide, so at a zoom of 1.5 it paints at 225 pixels — 150 × 1.5 — exactly the way your own transformed node scaled. A node placed at flow (0, 0) lands on screen at the viewport's translate, its position inside the viewport plus the viewport's own transform, the same composition your capstone measured. The edge path's coordinates are plain flow numbers, and because the <svg> carries no viewBox, the viewport's CSS transform maps them to the screen for free, precisely as your no-viewBox SVG did.

The one part you hand-wrote that React Flow does not is the pan/zoom arithmetic. It imports d3-zoom:

js · @xyflow/system 0.0.79 — the pan/zoom engine
import { zoom, zoomIdentity, zoomTransform } from 'd3-zoom';

There is no custom gesture maths in the library; d3-zoom produces the same three-number transform, including the zoom-about-a-point behaviour you derived by hand. That is the only piece it delegates — and it delegates it to a library, not to a canvas.

Why DOM and SVG, not canvas

So the "surely it's a canvas" instinct is exactly backwards. React Flow is ordinary DOM: one transformed div, absolutely positioned node divs, one <svg> per edge. And the reason is the free hit-testing you measured twice. On a canvas, everything is painted pixels — there is no element to hover, no element to focus, no element to receive a click, so you write every hit test, every hover, every focus ring, and every screen-reader affordance yourself. On DOM, all of that keeps working through the CSS transform: :hover highlights the node under the pointer, Tab moves focus between nodes, a click delegates like any other click, and a screen reader sees real elements. The transform moves them; the browser still treats them as what they are.

That is the whole payoff of this guide. The "advanced" node canvas is the beginner's box tree with one transform on it, moved by the loop, measured by the rulers, dragged by the pointer model, and drawn with just enough SVG — every piece something you can now measure and predict. You did not need the library to understand the library. You needed the layer underneath it, and now you have it.

/ close

When it won't move: debugging interaction

You wired up the handler and nothing happens. Or it happens but it stutters. Or the animation refuses to run, or the drag dies halfway. This is the situation the whole guide was for, so here is the procedure — the one you run on the interactions this guide never showed you.

The mindset first, and it is the same one from the layout guide: stop editing the line that looks wrong. An event that does not fire is almost never a bug in the handler; it is something upstream: a phase, a stopPropagation two elements up, a passive default. A janky animation is rarely the property you are watching; it is what that property forces the browser to redo. Open the browser's performance panel and its event listener inspector, and read what actually ran rather than what you meant to run. Then match the symptom.

text · match the symptom, then check the mechanism — and where it lives
the HANDLER never runs
  ├─ another handler called stopPropagation upstream?   it never reached you       (ch2) → up
  ├─ listening in the wrong phase?                      capture vs bubble           (ch1) → self
  ├─ preventDefault on wheel/touch did nothing?         the listener is passive     (ch2) → self
  └─ reading e.currentTarget expecting the click target? you want e.target          (ch1) → self

it's JANKY / stutters
  ├─ reading offsetWidth/getBoundingClientRect in a write loop? forced layout       (ch6) → self
  ├─ animating left / top / width / height?             not composited, relayouts   (ch11)→ self
  └─ wrapped it in rAF and it's still slow?             rAF doesn't cheapen the work (ch4) → self

a TRANSITION won't run (it jumps)
  └─ set the start and end in the same turn?            no committed start;
                                                        force a reflow between       (ch8) → self

an OBSERVER fires at the wrong time
  ├─ read a size and it's a frame stale?               IO is a task AFTER paint;
                                                        RO is in-frame BEFORE paint  (ch7) → self
  └─ "ResizeObserver loop completed…" in the console?  your callback resized what
                                                        it observes — break the loop (ch7) → self

a DRAG breaks
  ├─ dies when the pointer leaves the element?          no setPointerCapture         (ch12)→ self
  └─ scrolls the page on a phone instead of dragging?   no touch-action: none;
                                                        preventDefault can't fix it  (ch12)→ self

Notice how often the fix is on the element itself this time — interaction bugs are more local than layout bugs, because you usually own the handler. The exceptions that say up are the two that catch people: an event stopped by an ancestor, and a handler in the wrong phase to hear it.

The three questions underneath all of it

Every branch is really one of three questions, and if you can answer them you can debug an interaction you have never seen:

  1. Where is this in the loop? Is your code a task, a microtask, a rAF callback, an observer? That decides what has already happened and what has not — whether layout is current, whether the frame has painted, whether another callback beat you to it.

  2. What does this line cost? A geometry read on a dirty layout forces a synchronous layout. Animating a non-composited property relayouts every frame. The expensive operations are few and named; learn the short list and you can feel them coming.

  3. Who else touched this event, and where? An event is a trip through the tree. Something upstream may have stopped it, cancelled its default, or been promised it would not. The handler you are staring at is often downstream of the real cause.

What to hold onto

Strip the guide to its frame and it is four sentences. An interaction begins as an event that walks the tree, and you can read it, stop it, or delegate it. Your code runs inside a loop with a fixed per-frame order, and almost every timing surprise is a question of which slot you are in. Reading the page's geometry is cheap on a clean layout and ruinous on a dirty one, so batch reads before writes. And things move cheaply only when the compositor can move them, and the two it can move are transform and opacity, so the whole pannable canvas is one transform on an ordinary box tree.

You started this guide able to write handlers and hope. The difference now is that when an interaction fights you, you do not add another rAF and pray — you ask where you are in the loop, what the line costs, and who touched the event before you. That is the skill, and it is the same three questions every time.

Further reading

Everything above was measured in a browser or read from the source. When you want the normative rule behind a behaviour, these are the primary sources — read them against what you saw, not instead of it.

  • MDN Web Docs — Event reference — the readable companion to the event, phase and propagation model, each with a live example.
  • WHATWG DOM Standard — event dispatch, the three phases, preventDefault / stopPropagation, and the default-passive rule, defined normatively.
  • WHATWG HTML — Event loops — the processing model, the microtask checkpoint, and the ordered "update the rendering" steps that fix every per-frame timing.
  • CSSOM View Module — getBoundingClientRect, offsetWidth / clientWidth / scrollWidth, and what each one is measured against.
  • Resize Observer and Intersection Observer — where each observer sits in the frame, and the resize-loop-error algorithm behind that console message.
  • Web Animations Level 1 — element.animate(), play states, and the .finished promise.
  • CSS Transforms Level 1 — composition order, transform-origin, and that transforms apply after layout.
  • Pointer Events — the unified input model, setPointerCapture, implicit capture, and why touch-action is the only way to claim a gesture.
  • @xyflow/react source — the Viewport, node and edge components you read here, and d3-zoom doing the pan/zoom.