Transition
Vue's built-in Transition component: the six classes applied around enter and leave, naming them, JavaScript hooks, and appear.
<transition name="fade" mode="out-in" appear><p v-if="shown">text</p></transition><Transition> is a built-in component. It wraps one element or component and applies CSS classes while that element is being inserted or removed, so there is something to animate between "present" and "gone". Nothing registers it; it is available in every template. <Transition> and <transition> are the same component written two ways.
It acts on a child that actually enters or leaves: one controlled by v-if or v-show, a dynamic component, or an element whose key changed.
The six classes
| Class | When it is on the element |
|---|---|
v-enter-from | The start state, removed one frame after insertion |
v-enter-active | The whole entering phase; put the transition here |
v-enter-to | The end state, added one frame after insertion |
v-leave-from | The start state, added when leaving begins |
v-leave-active | The whole leaving phase |
v-leave-to | The end state, until the transition finishes |
The name prop changes the v- prefix: name="fade" gives fade-enter-from and the rest. name is shorthand for the six class props written out — enter-from-class, enter-active-class, enter-to-class, leave-from-class, leave-active-class, leave-to-class — which you use when the class names come from elsewhere, such as a utility framework.
JavaScript instead of CSS
The hooks are @before-enter, @enter, @after-enter, @enter-cancelled and the matching four for leave. @enter and @leave receive (el, done). Add :css="false" and Vue stops inspecting CSS; done() is then required, and the element waits in the DOM until you call it. @enter is shorthand for v-on:enter.
What trips people up
Only one element may be rendered at a time, so a v-if / v-else / v-else-if chain is fine inside a single <Transition> — only one branch ever exists. When the branches use the same tag name, give each a unique key, or Vue patches the one element instead of transitioning between them. appear animates the initial render only. By default enter and leave run at the same time, which overlaps two panels; mode="out-in" finishes the leave first.
Example
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(true)
function toggle(): void {
open.value = !open.value
}
</script>
<template>
<button type="button" @click="toggle">
{{ open ? 'Hide details' : 'Show details' }}
</button>
<transition name="fade" appear>
<p v-if="open" class="details">Shipping takes three to five days.</p>
</transition>
</template>
<style scoped>
.details {
margin-top: 0.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 180ms ease-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>When to use it
- Fade a validation message in when a field becomes invalid and out when it is fixed.
- Cross-fade two tab panels with mode="out-in" so the layout does not jump.
- Slide a cart drawer in and out without pulling in an animation library.
- Hand a leave animation to the Web Animations API when CSS cannot express it.
More examples
Longhand class names and out-in mode
The same transition as the main example, written with the six class props instead of name="fade" — the form to reach for when the class names come from a CSS framework rather than from you. The :key on the paragraph is what makes a tab change count as a leave and an enter; mode="out-in" means the old text has gone before the new text arrives, so the line does not double up.
<script setup lang="ts">
import { ref } from 'vue'
type TabId = 'overview' | 'specs'
const tab = ref<TabId>('overview')
</script>
<template>
<button type="button" @click="tab = 'overview'">Overview</button>
<button type="button" @click="tab = 'specs'">Specs</button>
<transition
mode="out-in"
enter-from-class="panel-hidden"
enter-active-class="panel-moving"
enter-to-class="panel-shown"
leave-from-class="panel-shown"
leave-active-class="panel-moving"
leave-to-class="panel-hidden"
>
<p :key="tab">
{{ tab === 'overview' ? 'A small desk lamp.' : '12 W, 2700 K.' }}
</p>
</transition>
</template>
<style scoped>
.panel-moving {
transition: opacity 150ms ease;
}
.panel-hidden {
opacity: 0;
}
.panel-shown {
opacity: 1;
}
</style>JavaScript hooks with the Web Animations API
With :css="false" Vue applies no classes and does not wait for a CSS transition; it waits for done(). Reach for this when the animation needs values you only know at runtime, or when you are driving an animation library. Forgetting to call done() in @leave leaves the element in the DOM for good.
<script setup lang="ts">
import { ref } from 'vue'
const saved = ref(false)
function onEnter(el: Element, done: () => void): void {
const animation = el.animate(
[
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'none' }
],
{ duration: 200, easing: 'ease-out' }
)
animation.onfinish = () => done()
}
function onLeave(el: Element, done: () => void): void {
const animation = el.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 120,
easing: 'ease-in'
})
animation.onfinish = () => done()
}
</script>
<template>
<button type="button" @click="saved = !saved">Save</button>
<transition :css="false" @enter="onEnter" @leave="onLeave">
<p v-if="saved" role="status">Saved</p>
</transition>
</template>Telling Vue the duration of a nested transition
Vue reads the transition duration off the root element to decide when the enter or leave is finished. Here the root fades in 150ms while a child slides for 400ms, so Vue would call it done too early and strip the classes mid-slide. :duration states the real figures; it takes a single number or an object with enter and leave.
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button type="button" @click="open = !open">Toggle receipt</button>
<transition name="outer" :duration="{ enter: 400, leave: 250 }">
<div v-if="open" class="card">
<p class="inner">Order #1042</p>
</div>
</transition>
</template>
<style scoped>
.card {
padding: 1rem;
}
.outer-enter-active,
.outer-leave-active {
transition: opacity 150ms ease;
}
.outer-enter-from,
.outer-leave-to {
opacity: 0;
}
.outer-enter-active .inner {
transition: transform 400ms ease-out;
}
.outer-enter-from .inner {
transform: translateY(12px);
}
</style>
Discussion