On this page

Transitions and animations: Transition, TransitionGroup, and JS hooks

12 min read TextCh. 5 — Production

Vue's transition system

Vue provides a built-in transition system that adds CSS classes to elements as they enter and leave the DOM (or toggle visibility). This class-based approach means you do all the actual animation in CSS — Vue just manages the timing and class application.

Two components drive the system:

  • <Transition> — for single elements or components
  • <TransitionGroup> — for lists of elements

The Transition component

<Transition> wraps a single direct child that toggles with v-if, v-show, or <component :is>:

<Transition name="fade">
  <div v-if="isVisible">Content</div>
</Transition>

Vue applies classes at specific moments:

Phase Enter classes Leave classes
Before insert {name}-enter-from {name}-leave-from
After insert + next frame {name}-enter-active {name}-leave-active
After transition ends {name}-enter-to {name}-leave-to

The {name}-enter-active and {name}-leave-active classes are applied throughout the entire transition — put your transition: CSS property here.

Common transition patterns

Fade:

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

Slide down:

.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
  overflow: hidden;
}
.slide-enter-from,
.slide-leave-to {
  max-height: 0;
  opacity: 0;
}
.slide-enter-to,
.slide-leave-from {
  max-height: 500px;
  opacity: 1;
}

Scale:

.scale-enter-active,
.scale-leave-active {
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.scale-enter-from,
.scale-leave-to {
  transform: scale(0.95);
  opacity: 0;
}

Transition modes

When switching between two elements, control the timing with mode:

<!-- out-in: old element leaves first, then new element enters -->
<Transition name="fade" mode="out-in">
  <component :is="currentView" :key="currentView" />
</Transition>

<!-- in-out: new element enters first, then old element leaves -->
<Transition name="fade" mode="in-out">
  ...
</Transition>

mode="out-in" is the most commonly used — it prevents both elements from showing at the same time.

appear — animate on initial render

Use appear to animate the element when the component first mounts:

<Transition name="fade" appear>
  <div v-if="loaded">Content</div>
</Transition>

Transition with dynamic names

You can dynamically switch transition names for different effects:

<script setup lang="ts">
import { ref } from 'vue'
const direction = ref<'forward' | 'backward'>('forward')
const page = ref(1)

function navigate(next: number) {
  direction.value = next > page.value ? 'forward' : 'backward'
  page.value = next
}
const transitionName = computed(() =>
  direction.value === 'forward' ? 'slide-left' : 'slide-right'
)
</script>

<template>
  <Transition :name="transitionName" mode="out-in">
    <PageView :key="page" :page="page" />
  </Transition>
</template>

TransitionGroup

<TransitionGroup> animates a list of elements. It renders a real DOM element (default: <span>; set tag to override). Every child must have a unique :key.

<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">{{ item.name }}</li>
</TransitionGroup>

In addition to enter/leave classes, <TransitionGroup> supports a move transition:

/* Applied when items shift position due to add/remove */
.list-move {
  transition: transform 0.4s ease;
}

Vue uses the FLIP technique (First, Last, Invert, Play) to smoothly animate position changes with pure CSS.

Staggered list animation

Stagger list items using CSS custom properties or JavaScript hooks:

<TransitionGroup
  name="stagger"
  tag="ul"
  @before-enter="setDelay"
>
  <li v-for="(item, index) in items" :key="item.id" :data-index="index">
    {{ item.name }}
  </li>
</TransitionGroup>
function setDelay(el: Element) {
  const index = Number((el as HTMLElement).dataset.index)
  ;(el as HTMLElement).style.transitionDelay = `${index * 0.05}s`
}

JavaScript animation hooks

For complex animations (GSAP, Web Animations API), use JavaScript hooks:

<Transition
  @before-enter="beforeEnter"
  @enter="enter"
  @after-enter="afterEnter"
  @enter-cancelled="enterCancelled"
  @before-leave="beforeLeave"
  @leave="leave"
  @after-leave="afterLeave"
  :css="false"
>
  <div v-if="show">Animated content</div>
</Transition>

Setting :css="false" tells Vue to skip CSS class management entirely — useful when JavaScript controls everything:

import { gsap } from 'gsap'

function enter(el: Element, done: () => void) {
  gsap.from(el, {
    opacity: 0,
    y: -20,
    duration: 0.4,
    ease: 'power2.out',
    onComplete: done, // MUST call done() to signal completion
  })
}

function leave(el: Element, done: () => void) {
  gsap.to(el, {
    opacity: 0,
    y: 20,
    duration: 0.3,
    ease: 'power2.in',
    onComplete: done,
  })
}

Transitions on route changes

Apply a transition to page navigation via <RouterView>:

<RouterView v-slot="{ Component, route }">
  <Transition :name="route.meta.transition ?? 'fade'" mode="out-in">
    <component :is="Component" :key="route.path" />
  </Transition>
</RouterView>

Set meta.transition in route definitions for per-route animations:

{ path: '/dashboard', component: Dashboard, meta: { transition: 'slide-left' } }

Accessible animations

Respect users' motion preferences:

@media (prefers-reduced-motion: reduce) {
  .fade-enter-active,
  .fade-leave-active,
  .list-enter-active,
  .list-leave-active,
  .list-move {
    transition: none !important;
    animation: none !important;
  }
}

Or detect the preference in JavaScript:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const duration = prefersReducedMotion ? 0 : 400

Practice

  1. Modal with transition: Create a modal dialog that fades and scales in when opened and scales and fades out when closed. Use mode="out-in" so the backdrop and content transition together.
  2. Animated todo list: Build a todo list using <TransitionGroup> where items slide in from the left when added and slide out to the right when removed. Add a .list-move transition for smooth reordering.
  3. Page transitions with Vue Router: Wrap your <RouterView> with a <Transition>. Read the transition name from route.meta. Implement two named transitions (slide-left and slide-right) and choose between them based on navigation direction.

In the next lesson, we will learn how to test Vue components with Vitest and Vue Test Utils — unit tests, component tests, and testing composables.

The list-move class is required for smooth reordering
When items in a TransitionGroup are reordered (not just added/removed), Vue applies a FLIP animation. For this to work smoothly, you must define a .{name}-move CSS class with a transition on the transform property.
Prefer CSS transitions over JS animations
CSS transitions run on the compositor thread and do not block JavaScript. Use the JavaScript hooks (@enter, @leave) only when you need precise control — such as reading the element's dimensions, using a library like GSAP, or animating properties that CSS cannot handle.
TransitionGroup requires :key on every child
TransitionGroup tracks elements by their :key. Without a stable :key, Vue cannot determine which elements were added, removed, or moved — transitions will not work correctly. Never use the loop index as a key in animated lists.
vue
<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)
const items = ref([
  { id: 1, text: 'Vue transitions' },
  { id: 2, text: 'CSS animations' },
  { id: 3, text: 'JS hooks' },
])
let nextId = 4

function addItem() {
  items.value.unshift({ id: nextId++, text: `Item ${nextId}` })
}

function removeItem(id: number) {
  items.value = items.value.filter(i => i.id !== id)
}
</script>

<template>
  <div class="demo">

    <!-- Single element transition -->
    <button type="button" @click="show = !show">Toggle</button>
    <Transition name="fade">
      <div v-if="show" class="box">Hello!</div>
    </Transition>

    <!-- List transitions with TransitionGroup -->
    <div class="controls">
      <button type="button" @click="addItem">Add item</button>
    </div>
    <TransitionGroup name="list" tag="ul" class="item-list">
      <li v-for="item in items" :key="item.id" class="item">
        {{ item.text }}
        <button type="button" @click="removeItem(item.id)">✕</button>
      </li>
    </TransitionGroup>

  </div>
</template>

<style scoped>
/* Single element: fade */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* List: slide + fade */
.list-enter-active,
.list-leave-active {
  transition: all 0.4s ease;
}
.list-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
/* MOVE transition — items that shift position */
.list-move {
  transition: transform 0.4s ease;
}
/* Ensure leaving items are taken out of layout flow */
.list-leave-active {
  position: absolute;
}

.box { padding: 1rem; background: #41b883; color: white; border-radius: 0.5rem; }
.item-list { list-style: none; padding: 0; position: relative; }
.item { display: flex; justify-content: space-between; padding: 0.5rem; margin-block: 0.25rem; background: #f1f5f9; border-radius: 0.25rem; }
</style>