Skip to content
intermediate Phase 12 · Transitions and Animations

Animating Lists with TransitionGroup

Animate list insertions, removals, and reordering with move classes.

45m
0 problems
Topic Progress 0%

Rows teleport until the move class runs the FLIP on them

Your task list animates correctly when items are added or deleted — the row fades in, the removed row fades out — but the moment you shuffle the array, every row that changed position teleports to its new spot. No slide, just a jump. The cause is structural: <Transition> wraps one element, while a reorder changes the position of many existing elements, so you need <TransitionGroup>, which applies enter, leave, and move transitions to each child of a v-for individually.

<TransitionGroup name="list" tag="ul" class="todo-list">
  <li v-for="task in tasks" :key="task.id" class="todo-row">
    {{ task.title }}
  </li>
</TransitionGroup>

The tag prop decides the wrapper element rendered around the children (ul here; without tag the group renders as a fragment with no wrapper at all). Enter and leave classes apply to the individual li children, never to the container — a common misread when the wrapper seems unanimated.

Adding fade-in/fade-out CSS is not enough for reorders. That needs the move class, and the mechanism behind it is the FLIP technique — First, Last, Invert, Play:

  1. First — before the patch, TransitionGroup records each child's viewport position (getBoundingClientRect) keyed by its vnode.
  2. Last — the DOM updates to the new order; the children are already at their final coordinates.
  3. Invert — for every child whose position changed, Vue writes an inline transform: translate(dx, dy) that carries it back to where it was, with transition-duration: 0s so nothing animates yet, then forces a reflow so the inverted position is painted.
  4. Play — the move class (list-move for name="list", or whatever you pass to move-class) is added and the inline transform is cleared; the browser now transitions from the inverted position to the natural one. Vue removes the move class on the transitionend of transform.
.list-enter-active,
.list-leave-active,
.list-move {
  transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
/* leaving items leave the flow so moving rows are measured correctly */
.list-leave-active {
  position: absolute;
}

Two lines in that block carry the weight. position: absolute on -leave-active takes the outgoing row out of layout while it fades, so the rows that slide up are computed against the final geometry rather than against a corpse occupying space. And transition must mention transform on the move class — more on why that is not optional in the next chapter's failure catalogue.

Insertions and moves also combine inside one update: while the new row fades in at its slot, the rows below slide down at the same time, because the enter runs on the inserted node while the FLIP pass runs on the surviving ones — and since positions are re-measured after the patch, the sliding rows are already at final coordinates and only the entering row's opacity is in flight. Nested groups work too: when a parent group's rows are themselves moving, Vue compensates for the parent's displacement while measuring the child group's rows, so a list inside a list does not double-animate.

Class Applied to Purpose
list-enter-from/-active/-to the inserted child animate insertion
list-leave-from/-active/-to the removed child, until removal completes animate deletion
list-move any child whose position changed slide it to its new slot

In an interview, the sentence that separates candidates is this: enter and leave handle membership changes, while the move class plus FLIP handles reordering — and all three need the same unique keys to know which DOM node belongs to which data item, which is where the next failure mode lives.

Index keys make your list animate the wrong row

TransitionGroup is strict about one thing: every child must carry a unique key. Forget it and the dev console tells you plainly — <TransitionGroup> children must be keyed. — but the subtler mistake passes the lint check: :key="index" from the v-for. It works, and it quietly disables most of what you installed the component for.

Watch what the diff does with index keys when a new task is inserted at the top of a five-row list. Before: keys [0,1,2,3,4]. After: the new row takes key 0, and every existing row's key shifts by one. The reconciler compares children position by position, and position 0's old vnode and new vnode share both type and key — so do positions 1 through 4. Vue patches the text in place on five existing DOM nodes and appends one new node at the bottom. No node ever moves, so the FLIP pass measures no position change and no move transition runs. The insertion animation plays on the last row, not the first, and any row-local state — a focused input, a checked box, an open dropdown — stays glued to the position instead of travelling with the task.

With :key="task.id" the same insert is one new vnode among five keyed survivors. The reconciler inserts the new node at the top, every other row keeps its own DOM node, their positions genuinely shift, and the move class slides them down while the new row fades in at the top. That is the whole reason the keys are mandatory: key identity is how Vue decides a row is the same row in a new place rather than new content in an old slot.

<!-- correct: node identity follows the data item -->
<li v-for="task in tasks" :key="task.id"> ... </li>

<!-- broken: node identity follows the array position -->
<li v-for="(task, index) in tasks" :key="index"> ... </li>

Two more key rules round out the contract. Keys must be unique within the list — duplicated ids make Vue patch an arbitrary match and animate the wrong row, so derive keys from something genuinely distinct (database id, SKU) rather than a display label that users can make collide. And don't recycle keys across different item types in the same list (task.id and user.id values sharing a keyspace); a collision between an old leaving row and a new entering row forces an early removal of the leaver mid-animation.

Key discipline also decides which row's enter and leave classes fire. With id keys, deleting the third of five tasks runs the leave on that task's own row — the one the user was looking at — and the rows below slide up to close the gap. With index keys, the same deletion removes whichever row occupies the last position: the fade plays at the wrong end of the list, while the surviving rows silently repatch their text to the tasks that followed. The data ends up correct either way; what the user watches during the transition does not. That is also why id keys matter for lists with inputs: the DOM node — and with it focus and draft text — stays attached to the item being edited instead of drifting to whatever now occupies that position.

The diagnostic is quick to run: shuffle your list with index keys and watch the DOM in devtools. If the same element nodes stay put while their text updates, your keys describe positions, and no amount of CSS will produce a slide. Fix the key first, then judge the motion — the class-name contract and its silent failures come next in JavaScript Transition Hooks.

The slide that never plays, and the 500-row list that stutters

A TransitionGroup list with correct keys still sometimes refuses to slide. The move machinery has real preconditions, and Vue checks one of them before doing any work: it probes whether an element with only the move class applied has a CSS transition on transform (it clones a child, strips the other transition classes, adds the move class, and inspects the computed transition). If the move class does not declare a transition whose property list includes transform — or all, which includes it — Vue skips the entire FLIP pass. The failure is silent: no warning, no console output, rows just teleport.

The full catalogue of ways the slide dies, in the order worth checking:

  1. Move class has no transform transition. transition: opacity 0.4s on .list-move animates nothing, because position changes are carried by transform. It must mention transform or all, with a nonzero duration.
  2. Animating top, left, or margin instead. These are layout properties: every frame the browser recomputes geometry for the whole list, which turns a 30-row shuffle into visible jank. FLIP exists precisely so the expensive part happens once and the animation rides the compositor.
  3. Leaving rows still in flow. Without position: absolute on .list-leave-active, the outgoing row occupies space while it fades, so rows measured for their slide are computed against the wrong layout and stop short or overshoot their slots.
  4. A renamed move class that nothing targets. move-class="reorder" applies reorder and nothing else — the generated name-move class is fully replaced, so your .list-move CSS never matches.
  5. Expecting a mode to fix the timing. <TransitionGroup> does not accept mode — the prop is not declared, because rows animate independently and there is no single in/out pair to serialize. That distinction from Transition Modes and appear is a favorite interview question.

Then there is cost. FLIP takes a getBoundingClientRect reading of every child before and after each update — O(n) layout reads per patch. At ten rows nobody notices; at a few hundred rows updating every few seconds (live feeds, trading tables, chat logs) the measurements alone compete with your render budget, and if rows also enter and leave with staggered JavaScript timelines the frame budget collapses. Mitigations that hold up: keep moves transform-only, throttle the data source feeding the list, animate only the rows the user can see, and for truly large or virtualized lists drop the per-row choreography entirely — virtualization recycles DOM nodes for different items, which corrupts the position bookkeeping TransitionGroup depends on.

A staggered entrance is the compromise most teams land on — small lists get the full slide, big ones fade rows in on a delay derived from the row index. Wiring that requires JavaScript per row, which is exactly the machinery in JavaScript Transition Hooks.

Sorting is the extreme case of the same tension: reordering thirty rows at once can set all thirty slides in motion simultaneously, each on its own transitionend — technically correct, visually chaotic. The pragmatic fix is a short move duration (250ms) so the shuffle resolves before it becomes the focus, plus the confidence that every moved row's class is cleaned off on its own transitionend; if you want a deliberate, index-staggered sequence instead, that is per-row JavaScript timing again.

Debugging checklist for a dead slide, in one line each: keys unique and data-derived? Move class declares transition: transform? -leave-active absolutely positioned? Positions changing at all (shuffle the data, not the CSS)? Each question localizes the fault to keys, CSS, flow, or data before you start rewriting the component.

Quiz

1. After a keyed reorder, what does TransitionGroup do first for a child whose position changed?

Question 1 options

2. A todo list keyed by array index inserts an item at the top. What does Vue's diff do?

Question 2 options

3. Vue probes whether a move animation can run. What must the move class provide?

Question 3 options

4. Which statement about <TransitionGroup tag="ol"> is correct?

Question 4 options

Flashcards

Question

When is the move class applied, and what is it named by default?

Answer

After an update changes a child's on-screen position — inserted and leaving children use the enter/leave classes, but surviving rows that shifted get the move class while they slide to their new slot. It defaults to `<name>-move` (v-move without a name) and is fully replaced by the move-class prop when set.

Question

Describe TransitionGroup's move animation as FLIP, in order.

Answer

First: record each child's viewport coordinates before the patch. Last: the DOM updates to the final layout. Invert: for moved children Vue writes an inline transform translating them back to the old coordinates with transition-duration 0s, then forces a reflow. Play: it adds the move class and clears the inline transform, so the CSS transform transition animates to the natural position; the class is removed on transitionend.

Question

Why must TransitionGroup children have unique, data-derived keys, and what do index keys break?

Answer

Key identity is how Vue decides a row is the same node in a new position rather than new content in an old slot. Index keys make every row's key shift on insert/delete, so nodes are patched in place: no moves are detected, the enter plays on the wrong end of the list, and row-local state (focus, checkboxes) sticks to positions. Duplicate keys patch an arbitrary match and animate the wrong row.

Question

What does the tag prop control, and where do the transition classes land?

Answer

tag sets the wrapper element rendered around the children (e.g. ul or ol); without it the group renders a fragment with no wrapper. Enter, leave, and move classes are applied to the individual children, never to the container.

Question

What two CSS conditions make list moves slide correctly?

Answer

(1) The move class must declare a transition including transform (or all) with nonzero duration — otherwise Vue's probe finds no transitionable transform and skips the FLIP entirely. (2) .list-leave-active should be position: absolute so leaving rows leave the flow and the remaining rows are measured against the final geometry.

Revision Notes

Key Takeaways

  • 1. TransitionGroup animates each v-for child individually: enter and leave cover membership changes, the move class covers reordering
  • 2. Moves are FLIP — Vue inverts each moved row with an inline transform, forces a reflow, then plays the slide through the move class and its transform transition
  • 3. Key identity drives everything: index keys make rows patch in place so nothing moves, wrong rows animate, and row state detaches from its data
  • 4. The move class must declare a transition on transform (or all); if it does not, Vue's probe skips the FLIP pass and the failure is completely silent
  • 5. Leaving rows get position: absolute so their fade does not corrupt the geometry the sliding rows are measured against

Interview Tips

  • • "How do list move animations work in Vue?" — name FLIP and walk the four steps; mentioning the forced reflow and the inline inverse transform shows you have read the mechanism, not just the CSS
  • • "Why does my list not animate when reordered?" — triage in order: are keys unique and data-derived, does the move class transition transform, are leavers taken out of flow, and is the data actually reordering (computed vs raw array mutation)
  • • "Transition vs TransitionGroup?" — one element in/out with optional modes versus per-child enter/leave/move on a keyed list with no modes; add that classes apply to children and tag only names the wrapper
  • • "When would you skip TransitionGroup?" — large or virtualized lists: FLIP measures every child twice per update and virtualization recycles nodes, corrupting position tracking; say what you would do instead (transform-only fades, throttled updates, animate only visible rows)

Cheat Sheet

Animating Lists with TransitionGroup

<TransitionGroup name="list" tag="ul">
  <li v-for="task in tasks" :key="task.id" class="todo-row">{{ task.title }}</li>
</TransitionGroup>

Class contract (classes land on children, never the wrapper):

Phase Classes Trigger
enter list-enter-from/-active/-to row inserted
leave list-leave-from/-active/-to row removed (stays mounted until done)
move list-move (or move-class) surviving row changed position

FLIP under the hood: record old coordinates → patch (rows already at final coordinates) → inline transform: translate(dx,dy) with transition-duration: 0s → forced reflow → add move class + clear inline transform → transitionend on transform removes it.

.list-enter-active, .list-leave-active, .list-move { transition: all 0.5s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
.list-leave-active { position: absolute; } /* leavers leave the flow */

Non-negotiables: unique data-derived keys per child (:key="item.id", never the index); move class must transition transform; mode is not supported (children animate independently); FLIP measures every child twice per update — costly on big or virtualized lists.

Continue Learning

Before You Start

Related Reading