Animating lists with TransitionGroup
Animate a list as rows enter, leave and change place, with a stable key on every row, the -move class that does FLIP for you, a leaving row taken out of the flow, and a stagger from the row's index.
What you'll learn
When you finish, you can do these.- 1Animate rows in and out with TransitionGroup, keyed by id
- 2Slide moved rows to their new places with the move class
- 3Close the gap smoothly while a removed row fades out
- 4Stagger rows so they enter one after another
A small online shop's basket page lists what you put in it: a mug, a teapot and a tray, each with a Remove button. Above the list sit two more buttons, Add to top and Reverse. Without animation, every click is a jump: remove the teapot and the tray snaps into the gap, reverse the list and the eye cannot tell what moved.
You need one idea from Enter and leave with Transition, where a note fades in and out: <Transition>, Vue's wrapper for one element, adds CSS classes to it while it enters or leaves, and your CSS turns them into a fade or a slide. <TransitionGroup> does that for every row of a list, plus one class for rows that change place. The Code tab holds the basket, App.vue, fully animated; by the end you can tune every rule in it.
A real element and a key for every row
The first step wraps the basket's rows in <TransitionGroup>, so each row fades and slides in and out. Two things differ from <Transition>.
First, <TransitionGroup> renders no element of its own unless you give it tag: tag="ul" makes it the list. Inside, v-for, the Vue attribute that repeats an element, makes one <li> per item in items, the basket's contents:
<TransitionGroup tag="ul" name="list" class="list" appear>
<li v-for="(item, i) in items" :key="item.id" :style="{ '--i': i }">
{{ item.name }}
<button :aria-label="`Remove ${item.name}`" @click="remove(item.id)">Remove</button>
</li>
</TransitionGroup>
The page shows three rows: Mug, Teapot and Tray. :style hands the CSS each row's index i (0, 1, 2) for a later step.
Second, every row needs a stable key: a value that tells Vue which row is which and never changes while the row exists, such as item.id, not the index i. Vue only knows that a row moved, rather than changed its text, by following keys. A row with no key puts <TransitionGroup> children must be keyed. in the preview.
The CSS for entering and leaving rows works as for <Transition>, and name="list" starts each class with list-. The -active classes say how long the change takes, -enter-from where a new row starts, and -leave-to where a leaving row ends:
.list-enter-active { transition: opacity 0.3s ease-out, transform 0.3s ease-out; }
.list-leave-active { transition: opacity 0.2s ease-in, transform 0.2s ease-in; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(24px); }
A new row fades in from 24 pixels to the right, and a removed row leaves the same way. appear runs the enter on the first render too, so the rows slide in when the preview loads.
The move class does FLIP for you
The move class, which <TransitionGroup> adds to every row that changed place, makes Reverse and Remove glide instead of jump. No CSS transition covers a change of place, so Vue uses a trick called FLIP: First, Last, Invert, Play.
- Firstmeasure
- Lastnew layout
- Inverttransform back
- Play-move to zero
Vue measures where every row is (First) and lets the page update, so each row lands in its new place (Last). Then it gives each moved row a CSS transform, a shift in where it is drawn, that puts it back where it was, so the screen still shows the old order (Invert). Right after, it adds the move class and removes the transform, and the row travels to its new place (Play).
With name="list", the move class is list-move, and your job is one rule:
.list-move { transition: transform 0.4s ease; }
Click Reverse: the mug and the tray swap places, gliding past the teapot. Without this rule, Vue skips the trick and the rows jump.
Leaving rows out of the flow, and a stagger from the index
Two finishing touches remain: rows that close the gap while a removed row fades, and a stagger, rows that arrive one after another.
A leaving row keeps its space until its fade ends, so if you removed the teapot, the tray would wait, then jump. Two rules in App.vue take the leaving row out of the flow, the layout where each row pushes the next one down:
.list { position: relative; }
.list-leave-active { position: absolute; left: 0; right: 0; }
With position: absolute, the tray's place changes as soon as the teapot starts to leave, so the move class slides it up while the teapot fades on top. left: 0; right: 0 and position: relative on the list keep the leaving row as wide as the list.
For the stagger, :style="{ '--i': i }" on the <li> sets --i, a CSS custom property (a variable CSS can read), to the row's index: 0 for the mug, 1 for the teapot, 2 for the tray. A transition-delay, the wait before a transition starts, grows with it:
.list-enter-active { transition-delay: calc(var(--i) * 60ms); }
When the preview loads, the mug enters at once, the teapot 60 milliseconds later and the tray at 120. Keep the delay on the enter only: a row you remove should go when you click.
What goes wrong
The first mistake is position: absolute on its own. Remove the teapot and its row shrinks as it fades. Nothing warns.
.list-leave-active { position: absolute; }
An absolute row is sized by its content, so it narrows to its name and button. left: 0; right: 0 stretches it again, across the nearest container with a position of its own: without position: relative on the list, that is the page, and the row sticks out past the list's edges.
.list { position: relative; }
.list-leave-active { position: absolute; left: 0; right: 0; }
The second mistake is the index as key.
<li v-for="(item, i) in items" :key="i">
Click Remove on the teapot, the middle row. The bottom row fades out while the middle row's text turns into Tray at once: the two rows left get keys 0 and 1, so Vue removes key 2 and reuses key 1. Click Reverse and nothing slides: every row keeps its key, and only the text changes.
<li v-for="(item, i) in items" :key="item.id">
Keyed by item.id, the teapot's own row leaves, and Reverse slides again.
What to remember
<TransitionGroup>animates every row of av-forlist with the classes of<Transition>, and renders a real element only when you give ittag.- Every row needs a stable key, such as an id. Index keys make the wrong row leave and turn a reorder into text changes.
- The
-moveclass is FLIP done for you: Vue measures, inverts with a transform, and yourtransition: transformplays it. - A leaving row needs
position: absolutewithleft: 0; right: 0inside a list withposition: relative, so the rows under it slide up while it fades. - A stagger is
transition-delay: calc(var(--i) * 60ms)with--iset from the row's index. Keep it on the enter.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Change
60msto120msin.list-enter-active. Replace the array of three items, brackets included, withArray.from({ length: 10 }, (_, i) => ({ id: i + 1, name: 'Item ' + (i + 1) })), andlet nextId = 4withlet nextId = 11, so new ids stay unique. The ten rows fan in one after another, the last about a second after the first. Click Add to top: the new row arrives at once, because its index is 0. Undo the three changes. - Delete
position: absolute; left: 0; right: 0;from.list-leave-activeand click Remove on Teapot. The teapot fades, the tray waits, then jumps up when the fade ends. Put the rule back, deleteposition: relativefrom.listand remove a row: it stretches to the preview's edges while it fades. Restore both. - Change
:key="item.id"to:key="i"and click Remove on Teapot: the bottom row fades out and the middle row reads Tray at once. Click Reverse: the names swap, nothing slides. Then delete the:keyattribute altogether: the preview shows<TransitionGroup> children must be keyed.Put:key="item.id"back and Reverse slides again.