A panel that pops into existence with no warning feels broken. The same panel fading in over 200 milliseconds feels finished. Vue's built-in <Transition> component makes that difference with no extra package. Wrap the element you show with v-if or v-show, describe its start and end states in CSS, and Vue plays the animation each time the element enters or leaves.
Everything here is plain Vue 3.5 in a Vite project.
Fade an element in and out
<Transition> is a built-in component, so you use it without importing anything. Put it around the element you toggle. This component shows a notice when you press a button:
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button type="button" @click="open = !open">Toggle notice</button>
<Transition name="fade">
<p v-if="open" class="notice">Saved. You can close this tab.</p>
</Transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
Without the wrapper, v-if inserts and removes the <p> in one frame. With it, Vue puts the fade- classes from the <style> block on the <p> while it enters and leaves. The element starts at opacity: 0, and the transition rule carries it to its normal opacity: 1 over 0.2 seconds. Leaving runs in reverse, and Vue removes the <p> from the DOM only once the transition has ended.
v-show works the same way. The one difference is that after leaving, the element stays in the DOM with display: none.
What the six classes do
Vue adds classes in two groups of three. Their prefix comes from the name prop: with name="fade" they start with fade-, as in the example above, and with no name they start with v-. One component can hold several transitions with different names, and they do not interfere with each other.
When the element enters, Vue:
- Adds
v-enter-fromandv-enter-active, then inserts the element. - One frame later, removes
v-enter-fromand addsv-enter-to. - When the transition ends, removes
v-enter-activeandv-enter-to.
Leaving follows the same pattern with v-leave-from, v-leave-active and v-leave-to, and ends with Vue removing the element.
So put the transition property in the -active classes, because they are present for the whole phase, and put the start and end states in -from and -to. A fade only needs -enter-from and -leave-to, since the resting state is the element's normal style.
To know when the transition has ended, Vue reads the element's computed style, looks for a transition-duration or an animation-duration, and listens for transitionend or animationend. If it finds neither, it removes the classes on the next frame, so an element with no matching CSS simply appears. If you use both a transition and an animation on the same element, tell Vue which one to wait for with type="animation" or type="transition".
Slide in with CSS keyframes
A CSS transition moves one property from one value to another: opacity 0 to 1, or a transform from translateY(12px) back to nothing. That covers most interface motion. A CSS animation with @keyframes can have more than two steps, for example an overshoot that goes past the end point and settles back. For keyframes you put the animation property in the -active classes and skip -from and -to. This slide goes in a global stylesheet, so any component can use it:
.slide-enter-active {
animation: slide-in 0.25s ease-out;
}
.slide-leave-active {
animation: slide-in 0.2s ease-in reverse;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Import the file once in src/main.ts (import './assets/transitions.css'), then use it with <Transition name="slide">. The leave rule plays the same keyframes backwards, which keeps the two directions symmetric and saves a second @keyframes block.
Whichever you choose, animate opacity and transform only. Those two properties do not trigger layout, so they stay smooth on slow phones. Animating height, margin or top makes the browser recalculate the layout on every frame.
Swap two elements with mode
A common need is to replace one element with another: a "Save" button becomes a "Saving" label, or tab A becomes tab B. With v-if and v-else inside one <Transition>, both elements animate at the same time by default. The new one enters while the old one leaves, so for a moment both are on screen and the layout jumps.
The mode prop fixes it:
<script setup lang="ts">
const props = defineProps<{ saving: boolean }>()
const emit = defineEmits<{ save: [] }>()
</script>
<template>
<Transition name="fade" mode="out-in">
<span v-if="props.saving" key="saving">Saving</span>
<button v-else key="idle" type="button" @click="emit('save')">Save</button>
</Transition>
</template>
mode="out-in" waits for the old element to finish leaving before the new one starts entering. The opposite, mode="in-out", is rarely what you want; out-in is the one to reach for.
The two key attributes are optional here: the template compiler already gives each v-if/v-else branch a key of its own, so Vue always sees two different elements. You need a key yourself when a single element stays and only its content changes. <span :key="label">{{ label }}</span> makes Vue replace the <span> each time label changes, which runs the leave and the enter. Without the key, Vue updates the text in place and nothing animates.
SaveButton.vue has no fade rules of its own, and the ones in Notice.vue are scoped to that component. Copy that <style scoped> block into SaveButton.vue, or move the fade rules into transitions.css; without them the two elements swap with no fade.
Animate on first render with appear
By default the enter transition runs only when the element appears after the initial render. A panel that is visible from the start just shows up. If you also want it to animate on page load, add the appear prop:
<Transition name="fade" appear>
<section class="welcome">Welcome back</section>
</Transition>
Use it sparingly. One element animating in on load is a nice touch; a page where ten things fade in is slow to read.
Animate from JavaScript with hooks
Sometimes CSS is not enough. You want a spring curve, or you are using an animation library that drives styles from JavaScript. <Transition> exposes the same phases as events: before-enter, enter, after-enter, enter-cancelled, and the four leave equivalents. Each hook receives the element, and enter and leave also receive a done callback you must call when the animation finishes.
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
function onEnter(el: Element, done: () => void) {
const animation = el.animate(
[{ opacity: 0, transform: 'scale(0.95)' }, { opacity: 1, transform: 'scale(1)' }],
{ duration: 180, easing: 'ease-out' },
)
animation.onfinish = () => done()
}
function onLeave(el: Element, done: () => void) {
const animation = el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120 })
animation.onfinish = () => done()
}
</script>
<template>
<button type="button" @click="open = !open">Menu</button>
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<div v-if="open" class="popover">Menu items</div>
</Transition>
</template>
This example uses the browser's own Web Animations API, el.animate(), so it needs no library at all. The important detail is :css="false". It tells Vue to skip the CSS detection and the six classes entirely, so the hooks are the only thing driving the animation and no stray stylesheet rule can interfere. Because the hooks declare a done argument, Vue waits for you to call it before it removes the element.
If you prefer a class-based library such as Animate.css, you do not need hooks. Pass the library's class names through enter-active-class and leave-active-class, and Vue applies them instead of the generated ones.
Turn animations off for reduced motion
Some people turn on "reduce motion" in their operating system because animation makes them dizzy or distracts them. Browsers expose that setting as the prefers-reduced-motion media query. Add this once, in a global stylesheet:
@media (prefers-reduced-motion: reduce) {
.fade-enter-active,
.fade-leave-active,
.slide-enter-active,
.slide-leave-active {
transition: none !important;
animation: none !important;
}
}
!important makes the rule win: Notice.vue's styles are scoped, so Vue compiles its .fade-enter-active to .fade-enter-active[data-v-…], which is more specific than the plain class here. Without !important the fade keeps running, and the slide stops only if main.css happens to load after transitions.css.
With no transition or animation to detect, Vue removes the classes on the next frame and the element appears at once. Nothing in your component changes, so you keep one code path. For JavaScript hooks, check window.matchMedia('(prefers-reduced-motion: reduce)').matches at the top of the hook and call done() straight away when it is true.
Keep one element inside <Transition>
<Transition> animates one element. A toast that puts two siblings inside it fails:
<Transition name="fade">
<strong v-if="open">Saved</strong>
<p v-if="open">Your changes are safe.</p>
</Transition>
In development the template compiler stops with <Transition> expects exactly one child element or component., and Vite reports it as an error. Code that skips that check, such as a render function, renders only the first element, with the console warning <transition> can only be used on a single element or component. Use <transition-group> for lists. The fix is a single wrapper:
<Transition name="fade">
<div v-if="open" class="toast">
<strong>Saved</strong>
<p>Your changes are safe.</p>
</div>
</Transition>
The same rule bites more quietly when the child is a component. A component whose template has several root nodes cannot be transitioned either, because Vue has no single element to attach the classes to. The page still works and the component just appears; the only sign is a development warning, Component inside <Transition> renders non-element root node that cannot be animated. Give the component one root element and it works. Lists that add and remove items are a different job; that is what <TransitionGroup> is for, and it deserves its own post.