Chaining D3 Transitions Without Animation Glitches
When you chain D3 transitions or fire them on rapid updates, marks snap to wrong positions, jitter between frames, or jump abruptly instead of easing — the classic signs of scheduler collisions.
This is a transition-lifecycle problem. The patterns below extend transition and animation sequences, and the underlying scheduler attaches to node identity defined by the enter-update-exit pattern.
The unifying cause behind snapping, jitter, and jumping is that two writers are contending for one attribute on one node within a single frame. The writers can be two D3 transitions, a D3 transition and a CSS rule, or a D3 transition and a fresh .data() rebind that re-applies the attribute directly. The cure is therefore always to establish a single owner per attribute and to clear any prior owner before installing a new one. Every fix in this guide is a concrete way to enforce that one rule.
Diagnostic checklist
Each item isolates one of the two-owner scenarios. The first two find competing animation systems, the third confirms the scheduler is even attached to the right nodes, and the last two catch the timing and type faults that surface as NaN snapping.
Root cause analysis
D3’s engine batches tweens through d3.timer and requestAnimationFrame, draining a priority queue at ~60fps. Each animated node carries its own schedule, and the shared timer advances every active schedule once per frame, computing an eased progress value and writing the interpolated result. The glitches you see are all artifacts of more than one schedule, or more than one system, writing the same property in the same frame. Three mechanisms produce that condition:
- Queue collisions:
.delay()only postpones execution; it does not clear existing state. When updates arrive faster than the delay window, queued tweens overlap on the same attribute and the value drifts. - Premature
end:.on('end')fires when a tween’s duration elapses, but during rapid polling a new transition can start before the previousendpropagates through the event loop, desyncing chained steps. - Implicit interpolator failure: D3 guesses the interpolator from value type. A number-to-string switch makes the default interpolator emit
NaN, producing frame drops and snapping.
The implicit-interpolator case is the most insidious because it works until the data shape shifts. When you write .attr('cy', (d) => vals[d.id]), D3 inspects the start and end values to pick an interpolator: two numbers get interpolateNumber, two color strings get interpolateRgb, two strings with embedded numbers get interpolateString. If the start is the number 0 and the end is the string "100px", the guess falls back to a string interpolator that cannot find a consistent numeric structure and emits NaN for intermediate frames, which the renderer either ignores (snap) or draws as a missing mark (flicker). The fix is to keep the value type stable across the whole animation — store and write plain numbers and let the attribute carry the unit implicitly, or supply an explicit interpolator via .attrTween so D3 never has to guess.
Broken vs fixed
// ❌ BROKEN: new transition stacked on an active one, no interrupt, no stable key
function updateNodes(selection: Selection<SVGCircleElement, Point, any, any>, vals: Record<string, number>) {
selection
.transition() // queues on top of any in-flight tween
.duration(400)
.attr('cy', (d) => vals[d.id]); // two tweens write cy → jitter and drift
}
// ✅ FIXED: interrupt guard clears the queue, then a deterministic transition
import { easeCubicInOut } from 'd3-ease';
function updateNodes(selection: Selection<SVGCircleElement, Point, any, any>, vals: Record<string, number>) {
selection
.interrupt() // halt active + queued tweens first
.transition()
.duration(400)
.ease(easeCubicInOut) // explicit easing, no implicit fallback
.attr('cy', (d) => vals[d.id])
.attr('opacity', 1); // single owner of each attribute
}
The two versions differ by one call, but that call changes the whole semantics. The broken version queues a new transition on top of whatever was running, so during rapid updates the node carries several live schedules all writing cy, and the rendered value oscillates between their curves. The fixed version calls .interrupt() first, cancelling every existing schedule so the new transition captures a clean starting value and is the sole writer; the explicit .ease(easeCubicInOut) removes the implicit-interpolator guess, and writing opacity in the same transition keeps that property under one owner too. Note that interrupting and re-scheduling every frame is wasteful — the streaming guard in the step-by-step section below bounds how often you start a transition so you are not interrupting and restarting sixty times a second.
For enter/update/exit chaining, order the phases so exits finish before enters animate:
const circles = svg.selectAll<SVGCircleElement, Point>('circle').data(data, (d) => d.id); // stable key
// exit first
circles.exit().transition().duration(300).attr('r', 0).attr('opacity', 0).remove();
// then merge enter + update and transition once
circles.enter().append('circle').attr('r', 0)
.merge(circles)
.transition().duration(500)
.attr('r', (d) => d.radius)
.attr('cx', (d) => xScale(d.x))
.attr('cy', (d) => yScale(d.y));
Step-by-step fix
The first three steps make a single transition deterministic; the last two make a stream of them deterministic by bounding how often transitions start. Apply them in order — an interrupt guard without explicit easing still risks NaN snapping, and stream guarding without an interrupt still overlaps.
- Add an interrupt guard. Prefix every state-driven transition with
selection.interrupt()to clear the queue. - Make easing explicit. Always set
.ease()so D3 never falls back to an implicit interpolator that can mistype the value. - Order enter/update/exit. Run the exit transition first, then merge enter with update and transition the combined selection once.
- Guard streams with
d3.active(). Skip scheduling when a transition is still running, and stash the latest payload to apply next frame. - Add a reduced-motion fallback. When
prefers-reduced-motionmatches, set duration to 0 so values jump without animating.
import { active } from 'd3-transition';
let pendingData: Point[] | null = null;
let scheduled = false;
function handleStreamUpdate(svg: Selection<SVGSVGElement, unknown, null, undefined>, data: Point[]): void {
pendingData = data;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
const running = active(svg.selectAll('circle').node() as SVGCircleElement);
if (!running && pendingData) {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // A11Y
updateNodes(svg.selectAll('circle'), toMap(pendingData), reduce ? 0 : 400);
}
scheduled = false;
pendingData = null;
});
// PERF: one rAF-scheduled apply per burst bounds the tween queue.
}
Verification
- Queue depth: after an update, log
d3.active(node); it should returnnullonce the transition completes. A persistent non-null value means orphaned transitions. - rAF density: record a Chrome DevTools Performance trace and filter by
requestAnimationFrame; overlapping frames at >60fps indicate redundant scheduling. - Reflow check: enable the Layout track; synchronous
getBoundingClientRect()during transition frames shows as forced reflow — cache dimensions before.transition(). - Latency: wrap a transition start with
console.time('t')/console.timeEnd('t'); >16ms signals main-thread blocking. - NaN guard: before scheduling, assert every target value is finite —
console.assert(Number.isFinite(target), 'non-finite tween target'). A singleNaNtarget produces the snap-and-flicker signature, and asserting at the source is far faster than tracing it from the rendered output. - Single-owner check: in DevTools, inspect the animated element’s computed style during a tween; if a CSS
transitionoranimationis listed on the same property D3 is writing, you have two owners — remove one.
Edge cases & gotchas
- Rapid polling / WebSocket: debounce updates and always
selection.interrupt()before applying; never trigger D3 transitions inside a framework render cycle. - React StrictMode: the double mount can start a transition twice — track init in a
useRefand clean up by interrupting in the effect’s teardown. - Stable container isolation: render one static
<div ref={...} />and let D3 own its children exclusively, so framework reconciliation never detaches a node mid-tween. If React re-renders and replaces a child D3 was animating, the schedule is orphaned and the new node starts with no transition, producing a visible jump on every framework update. - Named transitions for concurrent motion: when a node must animate two properties on different clocks — say a slow color fade and a fast position move — give each its own name (
.transition('color'),.transition('move')) so interrupting one does not cancel the other. An unnamed second transition silently replaces the first and one of the two animations never runs. - Zoom and resize: treat a zoom or resize as a stream of rapid updates, not a one-off. Debounce the redraw and interrupt before each applied frame, exactly as with a WebSocket feed, or the continuous gesture stacks transitions and the chart smears.
Related
- Transition and animation sequences — the parent guide for transition mechanics.
- Interrupting D3 transitions on rapid updates — the streaming-specific sibling.
- Enter-update-exit pattern mastery — node identity that the scheduler attaches to.