Animating Enter and Exit Selections Smoothly
New data appears instantly and removed data vanishes with a hard cut — your enter and exit elements pop instead of easing in and out.
The fix lives inside the enter/update/exit lifecycle covered by Enter Update Exit Pattern Mastery, the parent guide for this companion page. Popping happens because elements are appended at their final state and removed synchronously: there is no transition bridging the from-state and to-state, and without a stable key function D3 cannot even tell which elements are genuinely entering versus updating.
To animate anything, a transition needs two endpoints: a from value and a to value. The default join gives an entering element only one — its final state — because append() sets attributes immediately and the element exists at its destination from frame one. There is nothing to interpolate, so it pops in. The same gap exists in reverse on exit: .remove() deletes the node in the current frame, so even if you started a fade, the node is gone before the next paint and the fade never renders. Smooth enter and exit therefore come down to manufacturing the missing endpoint — appending entering elements in a deliberate from state, and deferring removal until an exit transition has run.
There is a deeper prerequisite, though: D3 must correctly classify each datum as entering, updating, or exiting, and that classification depends entirely on the key function. Without one, D3 binds by array index, so a reordered or filtered dataset looks like a wholesale change — many nodes appear to exit and re-enter even though the underlying entities are the same. The result is that everything animates at once on every update, which reads as flickering rather than the targeted enter/exit you intended. A stable key is not a nicety here; it is the thing that makes the three branches meaningful in the first place.
Diagnostic checklist
The lifecycle with transitions attached
Broken vs fixed
// ❌ BROKEN: enter at final state, synchronous remove — everything pops.
import { select } from 'd3-selection';
function render(data: Array<{ id: string; x: number }>): void {
select('g#dots')
.selectAll<SVGCircleElement, { id: string; x: number }>('circle')
.data(data) // no key fn: enter/exit are indistinguishable
.join('circle')
.attr('cx', (d) => d.x)
.attr('r', 6); // appears instantly; removed elements just disappear
}
// ✅ FIXED: keyed join, per-branch named transitions, remove on end.
import { select } from 'd3-selection';
function render(data: Array<{ id: string; x: number }>): void {
select('g#dots')
.selectAll<SVGCircleElement, { id: string; x: number }>('circle')
.data(data, (d) => d.id) // stable identity drives correct branch selection
.join(
(enter) =>
enter
.append('circle')
.attr('cx', (d) => d.x)
.attr('r', 0) // start hidden so there is something to animate from
.attr('opacity', 0)
.call((s) =>
s.transition('enter').duration(400).attr('r', 6).attr('opacity', 1),
),
(update) =>
// PERF: named transition isolates this from enter/exit so interrupts are surgical.
update.call((s) => s.transition('move').duration(400).attr('cx', (d) => d.x)),
(exit) =>
exit
.call((s) =>
s
.transition('exit')
.duration(300)
.attr('r', 0)
.attr('opacity', 0)
.remove(), // remove fires after the transition ends
),
);
// A11Y: keep aria-hidden off entering nodes only until they finish; announce via a live region elsewhere.
}
The fix has three moving parts: a key function so the right datum routes to the right branch, an initial from-state on enter, and a transition on the exit selection whose .remove() runs at the end rather than immediately.
Naming the transitions is what makes this robust under real update traffic. A bare .transition() is unnamed, and unnamed transitions on the same element cancel each other indiscriminately — so an exit fade can be killed by an unrelated position update that happens to fire mid-animation. By giving each branch its own name ('enter', 'move', 'exit'), you create independent channels: starting a new 'move' transition cancels only the previous 'move', leaving an in-flight 'exit' fade to finish. This separation is what lets a chart that updates several times a second still animate cleanly instead of dissolving into stutter. It also means that when you do need to stop an animation deliberately, you can interrupt one channel surgically rather than freezing everything on the element.
Step-by-step fix
Verification
Confirm exit nodes are gone only after the animation, not before:
const exitSel = sel.exit();
const before = exitSel.size();
exitSel.transition('exit').duration(300).attr('opacity', 0).remove();
// PERF: after the duration plus a frame, the nodes should be detached.
setTimeout(() => {
console.assert(select('g#dots').selectAll('circle[opacity="0"]').empty(),
'faded nodes were not removed — check the .remove() on the exit transition');
}, 360);
Visually, record a Performance trace during an update: enter circles should grow over the duration and exit circles should shrink, with no single-frame jump in the flame chart’s paint events.
Edge cases and gotchas
- Rapid updates overlapping the exit transition. If new data arrives while elements are mid-exit, the same key may re-enter; interrupt the exit transition and re-show the element rather than letting two transitions fight.
- The
thistween problem. Inside.attrTween/.tween,thisis the DOM node; an arrow function loses it. Use a regularfunctionsothisresolves to the element being animated. - List reordering. Animating position changes for reordered items needs the update branch to transition
cx/transform; without a key function the items teleport because D3 thinks they are different elements. - Staggered entry timing. For a pleasing cascade rather than a simultaneous appearance, add a per-element
.delay((d, i) => i * 20)on the enter transition. Keep the total stagger (count × delay) well under a second so the chart still feels responsive; on large datasets, cap the index used for delay so the last element does not wait seconds to appear. - Reduced-motion users. Respect the user’s
prefers-reduced-motionmedia query: when it is set, skip the transitions and apply final attributes directly. Animating against an explicit accessibility preference is both a usability and a WCAG concern, so gate the.transition()calls behind a check ofwindow.matchMedia('(prefers-reduced-motion: reduce)').matches.
Related
- Enter Update Exit Pattern Mastery — the parent guide for the join lifecycle.
- Interrupting D3 Transitions on Rapid Updates — stop overlapping transitions from glitching.
- Fixing Duplicate Nodes in D3 Enter Update Exit — the missing-key failure that also breaks animation.