Transition & Animation Sequences

Skip .interrupt() before a new transition and overlapping tween queues pile up until the dashboard thrashes the main thread and drops below 30fps.

Concept overview

A D3 transition interpolates DOM attributes and styles over a duration. Internally it schedules per-element tweens, queues them in a microtask-compatible scheduler, and drains them via requestAnimationFrame. Transitions run after the data join settles, so animation must align with the D3.js data binding and layout architecture to avoid state desync during rapid refreshes; specifically, transitions are choreographed around the enter-update-exit pattern so enter, update, and exit each animate at the right moment.

A transition is, mechanically, a derived selection: selection.transition() returns a Transition object that wraps the same nodes but schedules attribute changes over time instead of applying them instantly. Each node gets a per-node schedule — a small record holding the start time, delay, duration, easing, and the list of tweens (interpolators) to run. A single shared d3.timer, driven by requestAnimationFrame, ticks every active schedule each frame, computes the eased t ∈ [0, 1], and writes the interpolated value back to the attribute or style. Understanding that there is one timer draining many per-node schedules explains both the failure mode (two schedules writing the same attribute fight each frame) and the fix (interrupt() cancels a node’s existing schedules before a new one is attached).

The lifecycle has three controllable events — start, end, interrupt — and two tuning knobs:

  • Duration & easing: .duration(ms) and .ease(). Linear easing rarely suits data; prefer easeCubicInOut. Avoid spring/elastic easings for quantitative encodings — they distort perceived values by overshooting past the true endpoint.
  • Interrupt handling: call .interrupt() before scheduling a new transition on an active selection, or overlapping tweens cause jitter and drift.
  • Lifecycle hooks: .on('start', fn), .on('end', fn), and .on('interrupt', fn) run callbacks at schedule boundaries; .end() returns a Promise<void> that resolves when every node in the transition finishes (or rejects if interrupted), which is the modern, race-free way to sequence steps.
  • Named transitions: selection.transition('name') creates an independently-schedulable transition so a position tween and an opacity tween can coexist on one node without interrupting each other; selection.interrupt('name') then cancels only that one.
Transition scheduler timeline with interrupt A new transition arriving mid-flight without interrupt overlaps the previous tween, while interrupt clears the queue before the new one starts. no interrupt → overlap tween A (400ms) tween B starts early two tweens write the same attr → jitter interrupt() → clean handoff tween A cut tween B (clean start) queue cleared before B → no drift .interrupt() drops pending frames so the next transition owns the attribute
Without interrupt, a new transition overlaps the previous tween on the same attribute; calling interrupt clears the queue so each transition starts from a known baseline.

The reason overlap is so destructive deserves precision. When you call .transition() on a node that already has an active transition writing the same attribute, D3 does not replace the old one for that attribute unless the new transition has the same name — by default both schedules stay alive, and each frame both compute their own eased value and write it. The two writes race within the frame, so the rendered value oscillates between the two interpolation curves, which the eye reads as jitter. Worse, the second transition’s starting value is captured at the moment it is created, which is wherever the first transition happened to be mid-flight, so the new animation eases from an arbitrary point and the motion looks like it slips. interrupt() resolves both problems by cancelling the node’s existing schedules first, so the new transition captures a clean, settled starting value and is the sole writer of the attribute. This is why the rule is unconditional for any selection that might already be animating: interrupt, then transition.

Decision table: easing & sequencing choices

Need Use Avoid
Natural data motion easeCubicInOut, easePolyOut easeElastic, easeBounce (distorts values)
Coordinated multi-step .end() promise + await deep .on('end') callback nesting
Bulk insert smoothing .delay((_, i) => i * step) no stagger (layout spike)
Rapid stream updates .interrupt() per refresh unguarded repeated .transition()
Compositor-only motion transform, opacity tweens animating width/top/left
Independent concurrent tweens named transitions (.transition('pos')) one default transition for everything
Custom interpolation .attrTween / .tween with explicit interpolator relying on implicit type guessing

The compositor row is the highest-leverage one. D3 can tween any attribute, but the browser pays wildly different costs depending on which: transform and opacity are handled by the GPU compositor and skip layout and paint entirely, so a translate animation on thousands of nodes can stay at 60fps, whereas tweening cx, cy, x, y, or width reinvalidates layout every frame and collapses well before that. Where the visual result is equivalent, prefer transform: translate(...) over geometry attributes — position a <g> or use the transform attribute on the mark and animate that. The exception is that SVG geometry sometimes must change (a bar’s height genuinely is its data encoding), in which case the cost is unavoidable and you compensate by staggering and capping concurrency rather than by choosing a different property.

Reference spec

import { type Selection } from 'd3-selection';
import { easeCubicInOut } from 'd3-ease';

selection
  .interrupt()                 // cancel active + queued tweens
  .transition()                // Transition<...>
  .duration(400)               // ms
  .delay((d, i) => i * 20)     // per-element stagger
  .ease(easeCubicInOut)
  .attr('cy', (d) => d.value)
  .end();                      // Promise<void> resolving on completion

// d3.active(node): Transition | null  — inspect the running transition
// selection.interrupt(name?)          — clear a named or default transition

The full surface, with types, that production sequencing relies on:

transition.duration(ms: number | ((d, i, nodes) => number)): this;
transition.delay(ms: number | ((d, i, nodes) => number)): this;       // per-element stagger
transition.ease(fn: (t: number) => number): this;                     // easeCubicInOut, etc.
transition.attr(name: string, value: V | ((d, i, nodes) => V)): this; // implicit interpolator
transition.attrTween(name: string, factory: (d, i, nodes) => (t: number) => string): this;
transition.styleTween(name: string, factory: ...): this;
transition.tween(name: string, factory: (d, i, nodes) => (t: number) => void): this; // arbitrary
transition.on(type: 'start' | 'end' | 'interrupt' | 'cancel', fn): this;
transition.end(): Promise<void>;        // resolves on completion, rejects on interrupt
transition.selection(): Selection;      // escape back to the underlying selection

attrTween and tween are the escape hatches for anything the implicit interpolator cannot guess. The classic case is animating along an SVG path’s d attribute or interpolating an arc, where you supply a factory returning a per-frame function; the generic tween lets you drive any side effect — a Canvas redraw, a counter readout — off the same eased clock. Note that .end() rejects when the transition is interrupted, so an await transition.end() in a stream handler must be wrapped in try/catch or the interrupt that you deliberately issued will surface as an unhandled rejection.

Sequencing several phases — settle the axis, then move the marks, then fade in labels — is where promises replace the old callback nesting. Each step awaits the previous step’s .end(), giving linear control flow that cannot race even under rapid updates. The old approach, nesting .on('end', ...) callbacks, breaks under streaming because a new transition can start before the previous end event propagates through the event loop, so the next step fires against a stale state. The .end() promise resolves only when every node in the transition has genuinely completed, which makes await the correct primitive for ordered choreography.

Promise-sequenced transition phases Each phase awaits the previous transition's end promise so axis, marks, and labels animate in a guaranteed order. axis tween .end() → marks tween .end() → labels fade .end() → done await each .end() before starting the next — no on('end') nesting, no races
Awaiting each transition's end promise sequences axis, marks, and labels deterministically without nested end callbacks.

Step-by-step implementation

import { select } from 'd3-selection';
import { easeCubicInOut } from 'd3-ease';

interface Datum { id: string; value: number; }

async function executePhasedUpdate(group: SVGGElement, data: Datum[]): Promise<void> {
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const dur = reduce ? 0 : 400; // A11Y: collapse motion when requested

  const axisT = select(group).select('.x-axis').transition().duration(dur);
  await axisT.end(); // sequence: settle the axis before moving marks

  await select(group)
    .selectAll<SVGRectElement, Datum>('.bar')
    .data(data, (d) => d.id)
    .join(
      (enter) => enter.append('rect').attr('class', 'bar').attr('opacity', 0),
      (update) => update,
      (exit) => exit.transition().duration(reduce ? 0 : 200).attr('opacity', 0).remove(),
    )
    .interrupt() // PERF: clear stale tweens before scheduling
    .transition().duration(dur).ease(easeCubicInOut)
    .attr('opacity', 1)
    .attr('y', (d) => d.value)
    .end();
}

Performance & memory notes

Transitions are where a chart’s animation budget and its allocation budget collide, because every frame of every active tween both does main-thread work and (if it touches geometry) triggers layout. The cost model has three terms: the number of concurrently animating nodes, the per-frame cost of each tween’s interpolation and attribute write, and whether that write is a compositor property or a layout-invalidating one. Optimizing motion means pulling on all three — bound concurrency with interrupts and staggers, keep interpolators cheap, and animate transform/opacity wherever the data encoding allows.

  • Queue buildup: each unguarded .transition() on an active selection allocates another tween; under polling this grows unbounded. .interrupt() before each refresh bounds it.
  • Compositor vs layout: animate transform/opacity so changes stay on the GPU compositor; animating width/top forces synchronous layout each frame.
  • Read/write batching: read layout (getBoundingClientRect) before scheduling; reading during a transition forces a reflow. Cache any dimensions a tween needs once, before .transition(), and reference the cached values inside the tween factory rather than measuring per frame.
  • Stagger fan-out cap: .delay((_, i) => i * step) is excellent for spreading layout cost, but with thousands of nodes a fixed step pushes the final node’s start far into the future and keeps many schedules alive simultaneously. Clamp the effective delay (for example Math.min(i * step, maxDelay)) so the tail does not balloon the active-schedule count.
  • Canvas/WebGL: D3 transitions need DOM, so for rasterized engines cache start/end states and interpolate manually inside a requestAnimationFrame loop with interpolateNumber/interpolateRgb.
  • Schedule allocation: each transition allocates one schedule object per node plus its tween closures. A polling stream that schedules a fresh transition every tick without interrupting accumulates schedules faster than they drain, so the timer’s per-frame work grows unbounded — the memory and CPU face of the same overlap bug. interrupt() per refresh keeps the active-schedule count bounded to one per node.
  • Reduced motion is also a performance win: collapsing durations to zero under prefers-reduced-motion not only respects the user setting but eliminates all tween work, which is the cheapest possible update path for low-power devices.

Here is the manual-interpolation pattern for Canvas, where .transition() does not apply but the easing math still does:

import { interpolateNumber } from 'd3-interpolate';
import { easeCubicInOut } from 'd3-ease';

function animateCanvas(from: number, to: number, ms: number, draw: (v: number) => void): void {
  const interp = interpolateNumber(from, to); // pure: t → value
  const start = performance.now();
  function frame(now: number): void {
    const t = Math.min(1, (now - start) / ms);
    draw(interp(easeCubicInOut(t))); // PERF: redraw once per rAF, never per pointer event
    if (t < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

Accessibility checklist

Motion is an accessibility surface in two directions. For users who have requested reduced motion, animated repositioning can cause vestibular discomfort and obscures the very change it is meant to highlight, so collapse non-essential durations to zero and let values jump. For keyboard and screen-reader users, an animated update must not strand focus or leave aria-label showing a stale value mid-tween: update the accessible name to the final value at the start of the transition, not at its end, so assistive tech announces where the mark is going rather than narrating each interpolated frame. And no animation may flash more than three times per second, which rules out rapid blink or strobe encodings entirely.

Troubleshooting

The recurring theme is ownership: every animated attribute must have exactly one owner — one transition, one name, one system (D3 or CSS, never both). Every entry below is a variant of two owners fighting over one property.

  • Symptom: values jitter on rapid updates. Root cause: overlapping tweens. Fix: .interrupt() before each transition — see interrupting D3 transitions on rapid updates.
  • Symptom: elements snap mid-animation. Root cause: CSS transition competing with a D3 attr tween. Fix: own the property in one system — see chaining D3 transitions without animation glitches.
  • Symptom: NaN in interpolation. Root cause: a property shifting from number to string. Fix: keep value types consistent or supply an explicit interpolator.
  • Symptom: layout spikes on bulk insert. Root cause: no stagger, so hundreds of nodes invalidate layout on the same frame. Fix: add .delay((_, i) => i * step) to spread the cost across frames, and cap the total stagger so the last node does not start uncomfortably late.
  • Symptom: await transition.end() throws unhandled. Root cause: an interrupt rejects the end promise. Fix: wrap the await in try/catch and treat rejection as an expected cancellation, not an error.
  • Symptom: position and fade fight each other. Root cause: a second default transition replaces the first on the same node. Fix: give them distinct names (.transition('pos'), .transition('fade')) so they schedule independently.

Frequently Asked Questions

When must I call .interrupt()?

Call it before scheduling any new transition on a selection that may already be animating — typically on every frame of a polling or WebSocket stream. D3 does not auto-cancel prior tweens, so without interrupt the new and old tweens both write the animated attribute and the value drifts or jitters.

How do I sequence multiple animation steps cleanly?

Use the promise that transition.end() returns and await it between steps instead of nesting .on(‘end’) callbacks. Awaiting axisTransition.end() before animating the data marks gives linear async control flow and avoids the premature-callback races that arise under rapid updates.

Why do my transitions fight with CSS animations?

Because the browser compositor applies CSS transform and opacity rules independently of D3’s main-thread attribute tweens, so the two systems overwrite each other mid-frame. Pick one system per property: either animate with D3 attr/style tweens or with CSS, never both on the same attribute.

How do I animate on Canvas or WebGL where .transition() does not apply?

D3 transitions require DOM nodes, so for rasterized engines you cache the start and end values, compute an interpolation factor t each frame, and redraw inside a requestAnimationFrame loop. D3’s interpolateNumber and interpolateRgb give you the same easing math without the DOM scheduler.