Animations and transitions
Without animation, the page jumps from one state to the next when an element appears, disappears or moves. A short transition shows what changed. Vue ships two components for this, <Transition> for a single element and <TransitionGroup> for lists. Both add CSS classes at the right moments, and you write the animation itself in CSS.
In your project
Section titled “In your project”Transitions help where something would otherwise happen unnoticed, for example when a message appears, an entry leaves a list or a dialog opens. Keep them short, a few tenths of a second are enough. Some people get dizzy or nauseous from motion and tell their operating system that they want less of it. CSS reads that setting with the media query prefers-reduced-motion, and every animation in your project should respect it.
A small example
Section titled “A small example”<Transition> wraps an element that appears and disappears with v-if or v-show. When it appears, Vue sets the classes fade-enter-from, fade-enter-active and fade-enter-to one after the other, and the matching classes with leave when it disappears. The fade part comes from the name attribute. Vue waits until the CSS transition has finished and only then removes the element.
<script setup>import { ref } from 'vue'
const sent = ref(false)</script>
<template> <button type="button" @click="sent = !sent"> {{ sent ? 'Close the message' : 'Send the request' }} </button> <!-- Transition adds classes while the paragraph enters and leaves --> <Transition name="fade"> <p v-if="sent" class="notice">Your request has reached Lorraine Commons.</p> </Transition></template>
<style>.notice { padding: 0.5rem 0.75rem; border-left: 4px solid #2e7d32; background: #e8f5e9;}
.fade-enter-active,.fade-leave-active { transition: opacity 0.4s ease;}
.fade-enter-from,.fade-leave-to { opacity: 0;}
/* Whoever asks the system for less motion sees the change without the fade */@media (prefers-reduced-motion: reduce) { .fade-enter-active, .fade-leave-active { transition: none; }}</style>For lists, <TransitionGroup> handles every entry on its own. Each one needs a key, so Vue knows which entry arrives, leaves or moves. The class list-move animates the entries that change their place. So that the others can move up while an entry disappears, position: absolute takes the leaving entry out of the flow of the page.
App.vue
<script setup>import { ref } from 'vue'
import { things } from './things.js'
const favorites = ref(things.slice(0, 3))
function add(thing) { if (!favorites.value.some((favorite) => favorite.id === thing.id)) favorites.value = [...favorites.value, thing]}
function remove(id) { favorites.value = favorites.value.filter((favorite) => favorite.id !== id)}
function sortByName() { favorites.value = favorites.value.toSorted((a, b) => a.name.localeCompare(b.name))}</script>
<template> <p class="actions"> <button v-for="thing in things" :key="thing.id" type="button" @click="add(thing)">Add {{ thing.name }}</button> <button type="button" @click="sortByName">Sort by name</button> </p> <!-- TransitionGroup animates entries that enter, leave and move, each needs its key --> <TransitionGroup name="list" tag="ul" class="favorites"> <li v-for="thing in favorites" :key="thing.id"> {{ thing.name }} <button type="button" @click="remove(thing.id)">Remove</button> </li> </TransitionGroup></template>
<style>.actions { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.favorites { position: relative; padding: 0; list-style: none;}
.favorites li { display: flex; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.25rem 0;}
.list-move,.list-enter-active,.list-leave-active { transition: all 0.4s ease;}
.list-enter-from,.list-leave-to { opacity: 0; transform: translateX(2rem);}
/* A leaving entry leaves the flow, so the others can move up while it fades */.list-leave-active { position: absolute;}
@media (prefers-reduced-motion: reduce) { .list-move, .list-enter-active, .list-leave-active { transition: none; }}</style>things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', category: 'Tools', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', category: 'Tools', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', category: 'Parties', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', category: 'Tools', deposit: 40, availableFrom: '2026-11-05' },]Between pages, <Transition> animates the component that RouterView hands out through its slot. With mode="out-in", the old page leaves before the new one appears.
App.vue
<script setup>import { RouterLink, RouterView } from 'vue-router'</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/association">Association</RouterLink> </nav> <!-- RouterView hands over the page, Transition animates the change, out-in lets the old page leave first --> <RouterView v-slot="{ Component }"> <Transition name="page" mode="out-in"> <component :is="Component" /> </Transition> </RouterView></template>
<style>nav { display: flex; gap: 1rem;}
.page-enter-active,.page-leave-active { transition: opacity 0.25s ease, transform 0.25s ease;}
.page-enter-from { opacity: 0; transform: translateY(0.5rem);}
.page-leave-to { opacity: 0;}
@media (prefers-reduced-motion: reduce) { .page-enter-active, .page-leave-active { transition: none; }}</style>AssociationView.vue
<template> <section> <h2>Association</h2> <p>Open on Wednesday from 5 to 7 p.m. and on Saturday from 10 a.m. to 1 p.m.</p> </section></template>CatalogView.vue
<template> <section> <h2>Catalog</h2> <p>About 60 things in six categories.</p> </section></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import AssociationView from './AssociationView.vue'import CatalogView from './CatalogView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/association', component: AssociationView }, ],})In all three examples a media query switches the transitions off when the system asks for less motion. The change itself remains, it just happens without animation.
Browsers now bring their own interface for such transitions. document.startViewTransition() takes a picture of the page, lets you change the state and cross-fades from the old picture to the new one. The method exists in Chrome and Edge from version 111, in Safari from 18 and in Firefox from 144. Still check that it exists, and change the state directly otherwise.
Official links
Section titled “Official links”Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.