Skip to content
TutorialAnimation

Animate lists with <TransitionGroup>

<Transition> animates one element. <TransitionGroup> animates a whole v-for: items entering, items leaving, and the ones in between sliding to their new place.

vuejs.school team29 Jul 20256 min readVue 3.5

Without animation, a list changes in a single frame. Add a task and the rows below it jump down; delete one and the gap closes at once; shuffle and every row is suddenly somewhere else, so it is hard to see what happened.

Here you build a task list that fades rows in and out and slides the others into place, even on a shuffle, and a search result list whose rows appear one after another. The examples are plain Vue 3.5 single-file components, with no animation library.

Fade items in and out of a list

<TransitionGroup> wraps a v-for. It accepts the same props, CSS classes and JavaScript hooks as <Transition>, with these differences:

  • It renders no wrapper element unless you give it a tag, here a <ul>.
  • It has no mode prop, because items do not take turns the way two alternating elements do.
  • Every child must have a unique key.
  • The transition classes go on each item, never on the wrapper.

The task list below can add a task at a random position, remove one, or shuffle them all:

components/TaskList.vue
<script setup lang="ts">
import { ref } from 'vue'

interface Task {
  id: number
  title: string
}

let nextId = 4
const tasks = ref<Task[]>([
  { id: 1, title: 'Write the brief' },
  { id: 2, title: 'Review the design' },
  { id: 3, title: 'Ship it' },
])

function add() {
  const id = nextId++
  const at = Math.floor(Math.random() * (tasks.value.length + 1))
  tasks.value.splice(at, 0, { id, title: `Task ${id}` })
}

function remove(id: number) {
  tasks.value = tasks.value.filter(task => task.id !== id)
}

function shuffle() {
  tasks.value = [...tasks.value].sort(() => Math.random() - 0.5)
}
</script>

<template>
  <p>
    <button @click="add">Add</button>
    <button @click="shuffle">Shuffle</button>
  </p>
  <TransitionGroup tag="ul" name="list" class="tasks">
    <li v-for="task in tasks" :key="task.id">
      {{ task.title }}
      <button aria-label="Remove" @click="remove(task.id)">Remove</button>
    </li>
  </TransitionGroup>
</template>

<style scoped>
.tasks {
  position: relative;
  list-style: none;
  padding: 0;
}
.list-enter-active,
.list-leave-active {
  transition: opacity 300ms ease, transform 300ms ease;
}
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(24px);
}
</style>

The name="list" prop sets the prefix for the classes. When an item enters, Vue adds list-enter-from for one frame, then swaps it for list-enter-to while list-enter-active stays on for the whole transition. Leaving works the same way with list-leave-from, list-leave-to and list-leave-active. Your CSS describes the start and end states; the transition on the active class does the movement.

Click Add and the new row slides in from the right. Click Remove and the row slides out, then the rows below jump up to fill its place. Click Shuffle and nothing animates at all: the rows just swap places. The next section fixes both.

Slide items into place with the move class

When an item is inserted, removed or reordered, its neighbours change position. Vue can animate that too, through one more class, list-move. It uses a technique called FLIP, short for First, Last, Invert, Play:

  1. First: Vue records where each item is before the update.
  2. Last: after the browser lays out the new order, Vue records the new positions.
  3. Invert: a transform puts each moved item back where it was.
  4. Play: Vue adds list-move and removes the transform, so the browser transitions each item to its real position.

You only have to make transform transitionable on the move class; without that, Vue skips the move. In the <style scoped> block of TaskList.vue, replace the .list-enter-active, .list-leave-active rule with these two:

components/TaskList.vue
.list-move,
.list-enter-active,
.list-leave-active {
  transition: opacity 300ms ease, transform 300ms ease;
}

.list-leave-active {
  position: absolute;
}

The second rule fixes the jump. A leaving item keeps its space for the 300 milliseconds it takes to fade out, so the items below it cannot move until it is gone, and then they jump. position: absolute takes it out of the flow at once, so the others start their move transition immediately while it fades where it was.

Out of the flow, though, the leaving item shrinks to the width of its content. That shows when rows have a background or a border: add width: 100% to .list-leave-active to keep the leaving row full width. The position: relative on the <ul> is what makes that 100% mean the width of the list rather than the browser window.

Click Shuffle now and every row glides to its new place. Removing a row makes the ones below slide up as it fades. If you want a different class name for moves, pass move-class="my-move", exactly like enter-active-class on <Transition>.

Use stable keys, not indexes

Everything above depends on Vue knowing which item is which between renders, and it knows that from the key. Using the array index is the mistake.

components/TaskList.vueBreaks
<TransitionGroup tag="ul" name="list">
  <li v-for="(task, index) in tasks" :key="index">
    {{ task.title }}
  </li>
</TransitionGroup>

Remove the second of three tasks and the keys are now 0 and 1. Vue sees that key 1 still exists, so it keeps that <li> and swaps its text to the third task's title, then removes key 2. On screen the last row fades out and the second row's text changes instantly. Shuffle with index keys and nothing moves at all, because every key is still in the same position.

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

A key must identify the item, not its position. An id from the database is best. If the data has none, generate one when the item is created, as nextId does above, and never derive it from the position or from a value that two items could share.

Stagger entrances with JavaScript hooks

A search result list looks better when the rows appear one after another instead of all at once. Every item gets the same classes at the same moment, so each one needs its own delay. This component does it with JavaScript hooks: it tells the group not to apply CSS classes with :css="false", writes each item's index into a data-index attribute, and animates in the enter hook with a delay based on that index.

components/SearchResults.vue
<script setup lang="ts">
defineProps<{ results: { id: string; name: string }[] }>()

function onBeforeEnter(el: Element) {
  const row = el as HTMLElement
  row.style.opacity = '0'
}

function onEnter(el: Element, done: () => void) {
  const row = el as HTMLElement
  const index = Number(row.dataset.index)
  const animation = row.animate(
    [
      { opacity: 0, transform: 'translateY(8px)' },
      { opacity: 1, transform: 'none' },
    ],
    { duration: 250, delay: Math.min(index, 10) * 60, easing: 'ease-out', fill: 'forwards' },
  )
  animation.onfinish = () => {
    row.style.opacity = ''
    done()
  }
}

function onLeave(el: Element, done: () => void) {
  el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150, fill: 'forwards' }).onfinish = () => done()
}
</script>

<template>
  <TransitionGroup
    tag="ul"
    :css="false"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @leave="onLeave"
  >
    <li v-for="(result, index) in results" :key="result.id" :data-index="index">
      {{ result.name }}
    </li>
  </TransitionGroup>
</template>

What each part does:

  • el.animate() is the Web Animations API, built into every current browser, so no library is needed.
  • before-enter hides the element so it does not flash before its delayed animation starts.
  • enter and leave receive the element and a done callback. You must call done when your animation finishes, or Vue will think the transition is still running. Both hooks call it with no arguments, inside an arrow function: onfinish passes an event, which Vue would read as "cancelled" and run a @leave-cancelled listener instead of @after-leave.
  • The Math.min(index, 10) cap keeps the last row of a long list from waiting several seconds.
  • leave does not stagger, because people want removed items gone.

The index is still the right thing to put in data-index here, and the key is still the id. One is for the delay, the other is for identity.

Keep long lists fast

Two things cost time in a <TransitionGroup>. Every time the list changes, it reads the position of every item, and with a move class it reads them all again after the update, so a list of a thousand rows pays two thousand measurements per change. And every running transition is a composited layer the browser has to keep. Keep animated lists short: under a few dozen visible items is comfortable. For a long table, paginate or render only the visible rows, and animate the page, not the rows.

Animate only opacity and transform. Both can run on the compositor without triggering layout, which is why the examples above never transition height or margin. The move class already closes the gap a removed row leaves; to make the row itself look like it collapses, animate transform: scaleY().

Do not add appear to a long list to animate the first render either. It delays the moment the page becomes useful, for an effect the user sees once.

Check it stuck3 questions from the Animating lists with TransitionGroup quiz.
Practice now
Was this helpful?

Keep reading