TransitionGroup

Animate a list as items enter, leave and reorder with TransitionGroup, the move class, and keys that survive a reorder.

Syntax<transition-group name="list" tag="ul"><li v-for="item in items" :key="item.id" /></transition-group>

<TransitionGroup> is the built-in component for a list of children that change. <Transition> takes exactly one child and animates it appearing or disappearing; <TransitionGroup> takes many, and animates each one as it is added, removed or moved. Like <Transition> it needs no registration, and <transition-group> is the same component.

What is different

Three things. It renders no wrapper element by default; the tag prop gives you one, so tag="ul" produces a real <ul> around the items. It accepts every <Transition> prop except mode, because the children are not mutually exclusive. And every child must carry a unique key.

The move class

Besides the six enter and leave classes, which land on each item rather than on the container, there is a seventh: v-move, applied to items whose position changed. With name="list" it is list-move; the move-class prop (moveClass in a render function) names it yourself. Vue measures each item before and after the update and animates the difference with transform, so a move transition must be able to transition transform.

What trips people up

A removed item is taken out of layout flow only when you say so. Until its leave transition ends it still occupies space, so the rows below snap down at the end instead of sliding up. Add position: absolute to the leave-active class and make the container position: relative.

And the keys have to mean identity. :key (longhand v-bind:key) on the array index tells Vue that item two is still item two after a sort, so nothing moves.

Example

Example · vue
<script setup lang="ts">
import { ref } from 'vue'

interface Task {
  id: number
  label: string
}

const tasks = ref<Task[]>([
  { id: 1, label: 'Email the supplier' },
  { id: 2, label: 'Check stock levels' }
])
let nextId = 3

function add(): void {
  tasks.value.unshift({ id: nextId, label: `Task ${nextId}` })
  nextId += 1
}

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

<template>
  <button type="button" @click="add">Add task</button>

  <transition-group name="task" tag="ul" class="tasks">
    <li v-for="task in tasks" :key="task.id">
      {{ task.label }}
      <button type="button" @click="remove(task.id)">Remove</button>
    </li>
  </transition-group>
</template>

<style scoped>
.tasks {
  position: relative;
  list-style: none;
  padding: 0;
}

.task-enter-active,
.task-leave-active,
.task-move {
  transition: opacity 200ms ease, transform 200ms ease;
}

.task-enter-from,
.task-leave-to {
  opacity: 0;
  transform: translateX(-16px);
}

.task-leave-active {
  position: absolute;
}
</style>

When to use it

  • Fade a row into a task list when it is added and out when it is deleted.
  • Slide rows to their new position when a table is re-sorted.
  • Cross-fade a loading line into its loaded result without the block jumping.
  • Stagger a short set of cards in on first paint.

More examples

Move only: a reordering scoreboard

Nothing enters or leaves here, so the only class that matters is .row-move. Clicking +1 re-sorts the computed list and the row slides to its new rank instead of teleporting. Note that the sort runs on a copy: sorting players.value in place would mutate the source array. Keying on player.id is what lets Vue match each row to its old position.

Example · vue
<script setup lang="ts">
import { computed, ref } from 'vue'

interface Player {
  id: number
  name: string
  score: number
}

const players = ref<Player[]>([
  { id: 1, name: 'Ada', score: 3 },
  { id: 2, name: 'Grace', score: 5 },
  { id: 3, name: 'Katherine', score: 4 }
])

const ranked = computed(() =>
  [...players.value].sort((a, b) => b.score - a.score)
)

function bump(id: number): void {
  const player = players.value.find((candidate) => candidate.id === id)
  if (player) {
    player.score += 1
  }
}
</script>

<template>
  <transition-group name="row" tag="ol">
    <li v-for="player in ranked" :key="player.id">
      {{ player.name }} — {{ player.score }}
      <button type="button" @click="bump(player.id)">+1</button>
    </li>
  </transition-group>
</template>

<style scoped>
.row-move {
  transition: transform 300ms ease;
}
</style>

Staggered entry with JavaScript hooks

CSS has no way to say "each item 60ms after the one before", so the delay is computed per element. The index rides along on a data attribute, which the hook reads from el.dataset. appear is what makes the first render animate at all, and @appear is bound as well as @enter because with :css="false" each hook you want has to be provided. Keep stagger delays small: four items at 60ms is already a third of a second before the last one lands.

Example · vue
<script setup lang="ts">
const names = ['Ada', 'Grace', 'Katherine', 'Radia']

function onEnter(el: Element, done: () => void): void {
  const index = Number((el as HTMLElement).dataset.index ?? '0')
  const animation = el.animate(
    [
      { opacity: 0, transform: 'translateY(10px)' },
      { opacity: 1, transform: 'none' }
    ],
    { duration: 220, delay: index * 60, easing: 'ease-out', fill: 'both' }
  )
  animation.onfinish = () => done()
}
</script>

<template>
  <transition-group
    tag="ul"
    appear
    :css="false"
    @enter="onEnter"
    @appear="onEnter"
  >
    <li v-for="(name, index) in names" :key="name" :data-index="index">
      {{ name }}
    </li>
  </transition-group>
</template>

Swapping two mutually exclusive elements

This is not a list. A v-if / v-else pair can also go directly inside <Transition>, which handles branches fine because only one of them is ever rendered — reach for mode="out-in" there if you want them sequenced. <TransitionGroup> is the alternative when you want the two states to overlap in the same spot, and it requires a key on every child; the keys are static strings here because there are exactly two possible children. The leaving paragraph is pulled out of flow so the two states cross-fade in the same place rather than stacking.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'

const loading = ref(true)
const count = ref(0)

async function load(): Promise<void> {
  loading.value = true
  const response = await fetch('/api/orders/count')
  const data = (await response.json()) as { count: number }
  count.value = data.count
  loading.value = false
}

void load()
</script>

<template>
  <transition-group name="swap" tag="div" class="slot">
    <p v-if="loading" key="loading">Loading orders</p>
    <p v-else key="ready">{{ count }} orders</p>
  </transition-group>
</template>

<style scoped>
.slot {
  position: relative;
}

.swap-enter-active,
.swap-leave-active {
  transition: opacity 150ms ease;
}

.swap-enter-from,
.swap-leave-to {
  opacity: 0;
}

.swap-leave-active {
  position: absolute;
  top: 0;
  left: 0;
}</style>

Discussion

  • Be the first to comment on this lesson.