On this page
Transitions and animations: Transition, TransitionGroup, and JS hooks
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 : 400Practice
- 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. - 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-movetransition for smooth reordering. - Page transitions with Vue Router: Wrap your
<RouterView>with a<Transition>. Read the transition name fromroute.meta. Implement two named transitions (slide-leftandslide-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.
<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>
Sign in to track your progress