Vue 3 tutorial: animate with Transition and TransitionGroup
verified on 2 September 2026 · 5 min
<Transition> animates a single element as it appears and disappears, <TransitionGroup> animates a list. Vue applies CSS classes, your stylesheet decides the effect. Careful: in Vue 3 the starting classes are -enter-from and -leave-from, not -enter and -leave as in Vue 2.
An element that appears all at once produces a jarring jump. Vue ships two built-in components, <Transition> and <TransitionGroup>, which apply CSS classes at the right moment to animate entries and exits. No library to install.
The principle
Wrap an element whose display depends on a condition in <Transition>. Vue does not animate it itself: it adds and removes classes, and your CSS decides the effect.
Six classes cover the whole cycle.
/* enter */
.v-enter-from /* starting state, applied just before insertion */
.v-enter-active /* during the whole entry: this is where transition goes */
.v-enter-to /* end state */
/* leave */
.v-leave-from /* starting state of the exit */
.v-leave-active /* during the whole exit */
.v-leave-to /* final state, just before removal from the DOM */The v- prefix is the one of an unnamed transition. With name="fade", the classes become fade-enter-from, fade-enter-active, and so on.
In Vue 2, the starting classes were called .fade-enter and .fade-leave, with no suffix. Vue 3 renamed them -enter-from and -leave-from. An example copied from an old article will therefore produce no entry animation at all, without the slightest error message. If your transition does nothing, check this point first.
Animating availability
In ProductDisplay.vue, the stock message switches between two paragraphs depending on the hovered variant. Wrap them:
<Transition name="fade" mode="out-in">
<p v-if="inStock" key="in">En stock</p>
<p v-else key="out">En rupture</p>
</Transition>Then describe the effect in the stylesheet, as in the chapter on classes and styles:
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity .3s ease, transform .3s ease;
}Hover over the colour dots: the message fades instead of jumping.
mode="out-in" waits for the old element to leave before inserting the new one. Without it, the two paragraphs coexist for a split second and overlap.
What Transition accepts
One element at a time. That is the main constraint: <Transition> animates a single child, not a list.
It triggers in three situations: a v-if, a v-show, or a component switch through <component :is>. To force a transition between two elements of the same type, give them different keys, as above.
Animating a list
For a list, you need <TransitionGroup>. Two differences: it accepts several children, and it renders a container element itself if you ask it to with tag.
In ReviewList.vue, replace the <ul>:
<template>
<div class="review-container">
<h3>Avis</h3>
<TransitionGroup name="liste" tag="ul">
<li v-for="review in reviews" :key="review.id">
{{ review.name }} a mis {{ review.rating }} étoiles
<br>
« {{ review.review }} »
</li>
</TransitionGroup>
</div>
</template>Each child needs a unique, stable key. The index will not do here: it changes with every insertion, and Vue could no longer tell which element is new. Let us give each review an identifier when it is added:
<script setup>
function addReview(review) {
reviews.value.push({ ...review, id: crypto.randomUUID() })
}
</script>crypto.randomUUID() is available in every up-to-date browser, on pages served over HTTPS or from localhost.
The CSS adds one more class, -move, which animates the movement of the remaining elements when a neighbour disappears:
.liste-enter-from,
.liste-leave-to {
opacity: 0;
transform: translateX(24px);
}
.liste-enter-active,
.liste-leave-active,
.liste-move {
transition: all .3s ease;
}Submit several reviews: each one slides in, and the others rearrange themselves smoothly.
For -move to work on removal, the leaving element must be taken out of the flow, otherwise the others only move once its animation has ended. Add position: absolute to the -leave-active class when your elements can be deleted.
Animating the first render
By default, nothing is animated on the initial render. The appear attribute changes that:
<Transition name="fade" appear>
<p>Visible dès le chargement, en fondu</p>
</Transition>CSS animations and hooks
transition is not the only option: a CSS animation works too, applied on the -enter-active class.
To drive a JavaScript animation library, <Transition> emits events at each step:
<Transition
:css="false"
@before-enter="avantEntree"
@enter="entree"
@after-enter="apresEntree"
@leave="sortie"
>
<p v-if="visible">Contenu</p>
</Transition>With :css="false", Vue stops managing the classes and hands over to you. The enter and leave functions then receive the element and a done callback to call when the animation is over:
function entree(el, done) {
// animation with the library of your choice
done()
}Motion is not always welcome
Some people disable animations at the system level, in particular to avoid motion sickness. Respect that setting:
@media (prefers-reduced-motion: reduce) {
.fade-enter-active,
.fade-leave-active,
.liste-enter-active,
.liste-leave-active,
.liste-move {
transition: none;
}
}The interface stays functional, only the animation goes away.
This is the end of the tutorial
Your application displays a product and its variants, manages a cart, validates a form and animates its state changes. You have covered reactivity, directives, components, props, events and transitions: enough to read and write Vue every day.
Two directions for what comes next. Sharing state between distant components with Pinia, when bubbling events up becomes painful. And routing with Vue Router, as soon as the application has several pages.
<Transition> and <TransitionGroup> are built-in components: they are written exactly the same way. Only the JavaScript hook functions move, into the methods option.
key bound to the selected variant.Common errors
.fade-enter and .fade-leave were renamed -enter-from and -leave-from. Nothing animates and no error shows up.global: { stubs: { 'transition-group': false } } to observe the real rendering.