Staggering Transitions Across a Data Join
You want your bars to animate in one after another for that cascading reveal, but they all move at once — because a flat .duration() with no per-element delay starts every tween on the same frame.
This guide is part of D3 transition and animation sequences, covering the specific technique of offsetting each element’s transition by its index so a data join animates as a wave rather than a block. The core is a one-line change — .delay((d, i) => i * 40) — but doing it well means understanding how the delay accessor reads the join index, how to keep the total sequence inside a sensible time budget, and how to chain staggered phases without overlapping tweens.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: one duration, no delay — every bar moves on the same frame.
import { selectAll } from 'd3-selection';
import 'd3-transition';
interface Bar { id: string; value: number; }
selectAll<SVGRectElement, Bar>('rect.bar')
.transition()
.duration(600)
.attr('height', (d: Bar) => scaleY(d.value)); // all at once — no cascade
// ✅ FIXED: per-index delay staggers the starts; motion is guarded and interrupted.
import { selectAll } from 'd3-selection';
import { easeCubicOut } from 'd3-ease';
import 'd3-transition';
interface Bar { id: string; value: number; }
const STEP = 40; // ms between consecutive starts
const DURATION = 600; // ms each element animates
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
selectAll<SVGRectElement, Bar>('rect.bar')
.interrupt() // PERF: cancel any in-flight tween before starting a new one
.transition('grow') // named transition avoids clobbering unrelated ones
.delay((_d: Bar, i: number) => (reduce ? 0 : i * STEP)) // A11Y: no stagger when reduced
.duration(reduce ? 0 : DURATION)
.ease(easeCubicOut)
.attr('height', (d: Bar) => scaleY(d.value));
declare function scaleY(v: number): number;
The one indispensable line is .delay((_d, i) => i * STEP). The delay accessor receives the same (datum, index) signature as every other D3 accessor, and returning i * STEP shifts each element’s start by its position in the selection. Everything else — naming the transition, interrupting first, honouring reduced motion — is what turns a demo-quality stagger into one that survives rapid updates and respects the user.
Step-by-step fix
// Cap total stagger time so large joins do not crawl: keep the whole reveal under a budget.
function staggerDelay(count: number, budgetMs = 800, maxStep = 40) {
const step = Math.min(maxStep, budgetMs / Math.max(1, count - 1));
return (_d: unknown, i: number): number => i * step; // last element starts at ~budget
}
// Usage: selectAll('rect.bar').transition().delay(staggerDelay(bars.length)).duration(500)...
Verification
// Assert the delay accessor produces increasing, index-proportional offsets.
const delayFn = staggerDelay(5, 800, 40);
console.assert(delayFn(null, 0) === 0, 'first element starts immediately');
console.assert(delayFn(null, 1) > 0, 'second element is delayed');
console.assert(
delayFn(null, 2) === 2 * delayFn(null, 1),
'delay must be linear in the index',
);
In DevTools, record a Performance trace during the reveal and confirm the transition tasks are spread across successive frames rather than spiking on one, and that the last element’s tween ends near your intended budget. Toggle the “Emulate prefers-reduced-motion” rendering option and verify the bars snap to their final positions with no stagger.
Chaining staggered phases
A single stagger is one wave; coordinated sequences chain several. The clean way to run phase B after a staggered phase A is to start B from within A’s end event, or to add B as a chained .transition() on the same selection, which D3 schedules to begin when A completes for each element. The subtlety is that with a stagger, “A completes” happens at a different time for each element — element 0 finishes long before element n. If you want phase B to begin only after the entire wave settles, do not chain per-element; instead compute the total sequence time ((count - 1) * step + duration) and schedule B with a single setTimeout or a top-level delayed transition. If you want B to follow each element individually (a bar grows, then immediately fades its label), chain on the same selection so each element flows A→B on its own timeline. Choosing between “wait for the whole wave” and “each element continues on its own” is the main design decision in a multi-phase reveal, and getting it wrong produces either a stall (waiting for the slowest element) or a ragged overlap (phase B starting mid-wave).
Why interrupt before every stagger
Staggered transitions are especially prone to the overlapping-tween problem because their delays keep elements “pending” for a while before they start moving. If new data arrives during that pending window and you begin a second staggered transition without interrupting, D3 now has two transitions queued on the same elements — the first still waiting out its delay, the second layered on top — and the result is stutter, back-tracking, or elements that never reach their target. Calling .interrupt() (optionally .interrupt('grow') to target just the named transition) before starting the new one cancels the pending and active tweens cleanly so the new stagger owns the elements outright. This is the same interruption discipline that keeps any rapid-update chart smooth, and it matters more with staggers precisely because the delay widens the window in which a collision can occur. On a dashboard that re-renders on a data poll, interrupting before each staggered reveal is not optional — it is what stops the animation from degrading into jitter after a few refreshes.
Edge cases & gotchas
- Delay is constant, not indexed.
.delay(40)delays every element by the same 40ms, which is not a stagger; the argument must be a function ofi. - Runaway total time. A fixed 50ms step across 200 bars means the last one starts ten seconds in; cap the total with a computed step so large joins stay snappy.
- Join index versus visual order.
iis the position in the selection, which for a keyed, sorted join may not match left-to-right visual order; sort the selection or derive the delay from a data field if directional order matters. - Reduced motion ignored. A stagger is decorative motion; collapse delay and duration to 0 under
prefers-reduced-motionso the update is instant for users who requested it. - Enter-only staggers. Applying the stagger to the whole update selection re-animates unchanged elements on every refresh; scope the stagger to the enter selection so only new elements cascade in.
- Stagger fighting a layout transition. If positions are also transitioning, a per-index delay on one property and not the other desynchronizes them; delay the whole transition consistently or split into named transitions with matched delays.
Frequently Asked Questions
How do I make D3 elements animate one after another instead of all at once?
Give the transition a per-index delay: .delay((d, i) => i * 40). The delay accessor receives the datum and the selection index, and returning i times a step shifts each element’s start by its position, producing a cascade while every element shares one duration. A constant delay like .delay(40) delays everything equally and is not a stagger.
How do I stop a staggered transition from making large charts feel slow?
Cap the total sequence time by scaling the step to the element count instead of using a fixed step. Compute step = Math.min(maxStep, budget / (count - 1)) so the last element always starts near your time budget — for example 800ms — no matter how many elements there are. A fixed 50ms step across hundreds of elements pushes the final start many seconds out.
Why do my staggered transitions stutter on rapid data updates?
Because a stagger keeps elements pending during their delay, and starting a new transition before the previous one finishes stacks overlapping tweens on the same elements. Call .interrupt() before each new staggered transition to cancel the pending and active tweens so the new stagger owns the elements cleanly. The wide delay window makes staggers more collision-prone than immediate transitions.
Related
- D3 transition and animation sequences — the parent guide on frame-budget-safe motion.
- Chaining D3 transitions without animation glitches — sequencing multi-phase transitions cleanly.
- Interrupting D3 transitions on rapid updates — the interruption discipline a stagger depends on.
- D3 data joins and key functions — the join whose index drives the stagger.