---
title: "Animate lists with <TransitionGroup> · vuejs.school"
description: "Fade items in and out, slide neighbours into place when the order changes, and stagger a search result list, with the one key mistake that breaks all of it."
canonical_url: "https://vuejs.school/community/blog/animate-lists-with-transitiongroup"
meta:
  "og:description": "Fade items in and out, slide neighbours into place when the order changes, and stagger a search result list, with the one key mistake that breaks all of it."
  "og:image": "https://vuejs.school/_og/d/a_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-%2Cdescription_~RmFkZSBpdGVtcyBpbiBhbmQgb3V0LCBzbGlkZSBuZWlnaGJvdXJzIGludG8gcGxhY2Ugd2hlbiB0aGUgb3JkZXIgY2hhbmdlcywgYW5kIHN0YWdnZXIgYSBzZWFyY2ggcmVzdWx0IGxpc3QsIHdpdGggdGhlIG9uZSBrZXkgbWlzdGFrZSB0aGF0IGJyZWFrcyBhbGwgb2YgaXQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hbmltYXRlLWxpc3RzLXdpdGgtdHJhbnNpdGlvbmdyb3VwIg%2Cs_iIOdziVLNo1pj4aW.png"
  "og:image:alt": "Animate lists with <TransitionGroup> · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Animate lists with <TransitionGroup> · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-%2Cdescription_~RmFkZSBpdGVtcyBpbiBhbmQgb3V0LCBzbGlkZSBuZWlnaGJvdXJzIGludG8gcGxhY2Ugd2hlbiB0aGUgb3JkZXIgY2hhbmdlcywgYW5kIHN0YWdnZXIgYSBzZWFyY2ggcmVzdWx0IGxpc3QsIHdpdGggdGhlIG9uZSBrZXkgbWlzdGFrZSB0aGF0IGJyZWFrcyBhbGwgb2YgaXQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hbmltYXRlLWxpc3RzLXdpdGgtdHJhbnNpdGlvbmdyb3VwIg%2Cs_iIOdziVLNo1pj4aW.png"
  "twitter:image:alt": "Animate lists with <TransitionGroup> · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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](https://aerotwist.com/blog/flip-your-animations/), 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.vue**Breaks***

```
<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.vue**Works***

```
<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 stuck**3 questions from the Animating lists with TransitionGroup quiz.

[**Practice now**](https://vuejs.school/learn/animation/animating-lists-with-transitiongroup?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**Page transitions with the View Transitions API and motion-v**](https://vuejs.school/community/blog/page-transitions-with-the-view-transitions-api-and-motion-v) [Release notes · 6 min read**Motion for Vue 2.0: what changed**](https://vuejs.school/community/blog/motion-for-vue-2-what-changed)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-07-29","description":"Fade items in and out, slide neighbours into place when the order changes, and stagger a search result list, with the one key mistake that breaks all of it.","name":"Animate lists with \u003CTransitionGroup>","url":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup","breadcrumb":{"@id":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/animate-lists-with-transitiongroup"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-07-29","description":"Fade items in and out, slide neighbours into place when the order changes, and stagger a search result list, with the one key mistake that breaks all of it.","headline":"Animate lists with \u003CTransitionGroup>","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-%2Cdescription_~RmFkZSBpdGVtcyBpbiBhbmQgb3V0LCBzbGlkZSBuZWlnaGJvdXJzIGludG8gcGxhY2Ugd2hlbiB0aGUgb3JkZXIgY2hhbmdlcywgYW5kIHN0YWdnZXIgYSBzZWFyY2ggcmVzdWx0IGxpc3QsIHdpdGggdGhlIG9uZSBrZXkgbWlzdGFrZSB0aGF0IGJyZWFrcyBhbGwgb2YgaXQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hbmltYXRlLWxpc3RzLXdpdGgtdHJhbnNpdGlvbmdyb3VwIg%2Cs_iIOdziVLNo1pj4aW.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/animate-lists-with-transitiongroup#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Animate lists with \u003CTransitionGroup>","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-%2Cdescription_~RmFkZSBpdGVtcyBpbiBhbmQgb3V0LCBzbGlkZSBuZWlnaGJvdXJzIGludG8gcGxhY2Ugd2hlbiB0aGUgb3JkZXIgY2hhbmdlcywgYW5kIHN0YWdnZXIgYSBzZWFyY2ggcmVzdWx0IGxpc3QsIHdpdGggdGhlIG9uZSBrZXkgbWlzdGFrZSB0aGF0IGJyZWFrcyBhbGwgb2YgaXQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hbmltYXRlLWxpc3RzLXdpdGgtdHJhbnNpdGlvbmdyb3VwIg%2Cs_iIOdziVLNo1pj4aW.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~QW5pbWF0ZSBsaXN0cyB3aXRoIDxUcmFuc2l0aW9uR3JvdXA-%2Cdescription_~RmFkZSBpdGVtcyBpbiBhbmQgb3V0LCBzbGlkZSBuZWlnaGJvdXJzIGludG8gcGxhY2Ugd2hlbiB0aGUgb3JkZXIgY2hhbmdlcywgYW5kIHN0YWdnZXIgYSBzZWFyY2ggcmVzdWx0IGxpc3QsIHdpdGggdGhlIG9uZSBrZXkgbWlzdGFrZSB0aGF0IGJyZWFrcyBhbGwgb2YgaXQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_peach%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hbmltYXRlLWxpc3RzLXdpdGgtdHJhbnNpdGlvbmdyb3VwIg%2Cs_iIOdziVLNo1pj4aW.png"}]}
```