Skip to main content

Transitions — declaring enter and leave animations

Chapter 13 covered "where to render" and "when to render". This chapter is about "how things come and go". <Transition> animates one element entering and leaving; <TransitionGroup> animates a list entering, leaving, and reordering.

Neither writes the animation itself. All they do is add and remove classes at fixed moments and call JavaScript hooks. The actual motion comes from CSS or from code you write.

What you'll learn in this chapter​

  • The order the six classes are applied in, and what "one frame later" actually is
  • How Vue decides a transition has ended, and where that goes wrong
  • What decides whether a JavaScript hook waits on done
  • How the hooks change when you add appear
  • The condition under which TransitionGroup can follow reordering
  • How it looks when combined with the other built-in components

Distributing classes for enter and leave​

<Transition> applies enter and leave animations to the contents of the default slot. The official guide lists four triggers: v-if, v-show, switching <component :is>, and changing the key.

components/FadeBox.vue
<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)
</script>

<template>
<button type="button" @click="show = !show">Toggle</button>

<Transition name="fade">
<p v-if="show" class="box">A paragraph that fades</p>
</Transition>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

Adding name makes the class prefix that name. Without it you get v, as in v-enter-from. There are six enter and leave classes (three more when using appear).

ClassApplied atRemoved at
*-enter-fromImmediately before insertionOne frame after insertion
*-enter-activeImmediately before insertionWhen the transition ends
*-enter-toOne frame after insertionWhen the transition ends
*-leave-fromImmediately after leave beginsOne frame later
*-leave-activeImmediately after leave beginsWhen the transition ends
*-leave-toOne frame after leaveWhen the transition ends

Only *-active is applied from start to finish, so that is where the transition declaration goes.

The slot takes only one element or component. If it is a component, that component must itself have a single root element.

What "one frame later" actually is​

Measured, the class state moves like this.

MomentClasses applied
Immediately before insertionfade-enter-from + fade-enter-active
After crossing requestAnimationFrame twicefade-enter-active + fade-enter-to
After the endNeither

"One frame later" is a doubly nested requestAnimationFrame.

function nextFrame(cb) {
requestAnimationFrame(() => {
requestAnimationFrame(cb)
})
}

One is not enough because removing enter-from before the browser has registered that state means the transition never starts. When mixing in code that touches styles yourself, what you do during those two frames changes the result.

There are also props for specifying class names directly (enter-from-class and friends). You can write several classes separated by spaces, which is what you use when working with utility-class CSS.

<Transition
enter-from-class="opacity-0 scale-90"
enter-active-class="transition-all duration-300"
enter-to-class="opacity-100 scale-100"
>
<p v-if="show">Body</p>
</Transition>

How the end is determined​

Vue reads the delay plus duration of transition and animation from the element's computed style and takes the longer. It then counts transitionend or animationend events.

timeout = Math.max(transitionTimeout, animationTimeout)
type = timeout > 0 ? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION) : null

The quoted branch is what happens when you do not write type. It has quirks.

  • If both are 0, type becomes null and it is treated as finished on the spot. A <Transition> whose CSS you forgot passes straight through doing nothing
  • The comparison uses a strict inequality, so equal lengths mean animation wins

Writing type makes it look only at that kind's duration. Write type="transition" with only an animation attached and you get the same "0, so finish immediately" as above.

The events being counted have conditions too. Only events from the element itself are counted, and it finishes once the count reaches the number of comma-separated durations. Events bubbling up from children are not counted.

That becomes a problem when animating nested elements. Events coming up from the inside are not counted. So it is treated as finished once the outer element's own transition ends, and a long transition-delay on the inside is not waited for. With nothing declared on the outside, it is judged 0 and finishes immediately.

Making the outer transition longer than the inner one lets it wait, but that is manually copying the inner timing onto the outer. Stating duration overrides it.

<Transition :duration="550">...</Transition>
<Transition :duration="{ enter: 500, leave: 800 }">...</Transition>

Pass duration and, for that direction, no events are listened for at all. The end is decided purely by a setTimeout for the stated time, so the classes come off even if the real animation runs long. Write only one side in the object and the other returns to automatic detection.

Automatic detection is unreliable when a transition and an animation are applied to the same element. That is the one case where you state type.

<Transition type="animation">...</Transition>

Writing it in JavaScript​

For what CSS cannot express — a height animation depending on the element's real dimensions, say — use the JavaScript hooks. There are eight enter and leave hooks (four more for appear, twelve in total). The official guide explains leave-cancelled as being v-show-only.

To write it purely in JavaScript, add :css="false". That stops class application and automatic detection wholesale, so CSS cannot interfere by accident.

components/HeightBox.vue
<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)

function onEnter(el: Element, done: () => void) {
const box = el as HTMLElement
box.style.height = '0'
box.animate([{ height: '0' }, { height: `${box.scrollHeight}px` }], { duration: 300 })
.finished.then(() => {
box.style.height = ''
done()
})
}

function onLeave(el: Element, done: () => void) {
const box = el as HTMLElement
box.animate([{ height: `${box.scrollHeight}px` }, { height: '0' }], { duration: 300 })
.finished.then(done)
}
</script>

<template>
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<div v-if="show" class="box">Content whose height changes</div>
</Transition>
</template>

There is one thing to pin down here. Whether done is waited on is decided by the number of parameters the hook declares.

if (hook.length <= 1) {
done()
}

Write two parameters and it waits until you call done(). One or fewer and it does not wait. What happens next splits on the css setting.

cssOne parameter or fewerTwo or more parameters
falseVue calls done() immediatelyWaits until done() is called
Default (enabled)Waits for the CSS to finishWaits until done() is called (the CSS end is not consulted)

So writing (el) => { el.animate(...) } with :css="false" moves on without waiting for the animation, and under either setting, forgetting to call done() in (el, done) => { ... } means it never finishes. What the check uses is the function's length. length does not count a parameter with a default, nor anything after it, so (el, done = noop) => { ... } is treated as taking one parameter. Looking only at the declaration and assuming "it receives done" means it moves on without waiting.

Applying it to the first render​

Adding appear runs a transition on the first render too.

<Transition appear name="fade">
<p>A paragraph present from the start</p>
</Transition>

Both classes and hooks fall back to the enter versions for whatever you did not write for appear. Without appear-from-class you get *-enter-from; without @after-appear you get @after-enter.

Turn that around and whatever you do write replaces the enter version. Write @before-appear and @appear and only those are called on the first render, with @before-enter and @enter skipped. Put work in both expecting both to run and one goes missing on the first render alone.

Swapping two elements​

Switching two elements with v-if / v-else leaves both in the DOM at the same time by default. position: absolute is the standard way to avoid the overlap, and where that is not an option, mode puts them in order.

<Transition mode="out-in" name="fade">
<p v-if="saved" class="saved">Saved</p>
<p v-else class="editing">Editing</p>
</Transition>

out-in waits for the old one to leave before bringing the new one in. There is also in-out, which the official guide describes as "much less frequently used".

name can be dynamic, which is what you use to switch animations based on state.

<Transition :name="direction === 'next' ? 'slide-left' : 'slide-right'" mode="out-in">
<p :key="page">Page {{ page }}</p>
</Transition>

The key is there because changing only the contents of the same element triggers no transition. Changing the key makes it an element swap — one of the four triggers listed at the start of this chapter.

List enter, leave, and reordering​

<TransitionGroup> handles lists. The three main differences from <Transition> are these.

  • It renders no wrapper element by default. Pass tag to wrap in that element
  • The elements inside always require a key. Without one you get the warning <TransitionGroup> children must be keyed., but only in the development build. Production says nothing and reorder tracking goes wrong
  • mode is unavailable, since it is not swapping two mutually exclusive things
components/SlideList.vue
<script setup lang="ts">
import { ref } from 'vue'

interface Item {
id: number
name: string
}

let nextId = 3
const items = ref<Item[]>([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
])

function add() {
nextId += 1
items.value.push({ id: nextId, name: `Fruit ${nextId}` })
}

function remove(id: number) {
items.value = items.value.filter((item) => item.id !== id)
}
</script>

<template>
<button type="button" @click="add">Add</button>

<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id" @click="remove(item.id)">
{{ item.name }}
</li>
</TransitionGroup>
</template>

<style scoped>
.list-enter-active,
.list-leave-active,
.list-move {
transition: all 0.3s ease;
}

.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}

.list-leave-active {
position: absolute;
}
</style>

*-move is the class used for reordering. The mechanism is FLIP: after the update it cancels the difference between the old and new positions with a transform, then removes that and lets it animate.

If this class does not declare a transition on transform, no reorder animation runs at all. The way it is checked is peculiar. Vue clones the element, applies *-move, inserts it temporarily into the DOM with display: none, and looks for transform or all in the computed transition-property. Forget .list-move { transition: transform 0.3s } and everything is skipped at that point.

The check goes through the automatic detection from the previous section without a type. Being selected as a transition is also a condition, so an animation hitting the clone that is as long or longer means animation wins and FLIP is skipped even with a transform transition written. It happens not only from an animation written alongside *-move but also from one on the element itself. Writing <TransitionGroup type="transition"> does not affect this check.

Adding .list-leave-active { position: absolute } is standard too. Without taking the departing element out of normal flow, it overlaps the movement of the remaining ones and positions jump.

Combining with other built-ins​

There is a fixed nesting order when combining <Transition> with <KeepAlive>, the <Suspense> from chapter 13, or Vue Router's <RouterView>. This is the shape the official guide's Suspense page gives.

<RouterView v-slot="{ Component }">
<template v-if="Component">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>Loading</template>
</Suspense>
</KeepAlive>
</Transition>
</template>
</RouterView>

This shape works when measured. Two things measurement revealed.

Lifecycle hooks come after the boundary resolves. The first pass ran pending → fallback → resolve → onMounted → onActivated. Suspense defers post-flush work until resolution.

The previous content also stays during a swap. The Suspense behaviour from chapter 13 (keep showing the previous content while waiting during a swap) is unchanged by putting mode="out-in" in the way. The old one is removed when the boundary resolves, and it is untouched while pending. The measured order was pending → (the new one resolves) → resolve → the old one's onDeactivated → the new one's onMounted. No fallback appears on this route, because no timeout was written for the swap.

Summary​

  • <Transition> does not write animations. It adds and removes classes at fixed moments and calls JavaScript hooks
  • The official guide's four triggers are v-if, v-show, switching <component :is>, and changing the key. The slot takes only one element or component
  • There are six enter and leave classes. Only *-active is applied from start to finish, so transition goes there. name changes the prefix, defaulting to v
  • "One frame later" is a doubly nested requestAnimationFrame. One is not enough: enter-from would come off before the browser registered the state
  • The class-name props accept several classes separated by spaces
  • The end is determined from the computed style's delay plus duration. Without type the longer wins, both being 0 passes straight through doing nothing, and equal lengths mean animation wins. Only events from the element itself are counted, waiting until the comma-separated count is reached. Events coming up from inner elements are not counted
  • Pass duration and, for that direction, no events are listened for and only a setTimeout decides. Write only one side in the object and the other returns to automatic detection
  • State type when a transition and an animation are on the same element. It does not, however, affect the *-move check in TransitionGroup
  • There are eight enter and leave JS hooks (twelve with the four for appear). Whether done is waited on is decided by the number of parameters the hook declares. Two or more waits for the call; one or fewer calls done() immediately under :css="false", or waits for the CSS to finish when CSS is enabled. length does not count parameters with defaults, so (el, done = noop) => {} counts as one parameter
  • :css="false" stops class application and automatic detection wholesale
  • appear makes both classes and hooks fall back to the enter side for whatever you did not write. What you do write replaces the enter version, so writing @appear means @enter is not called on the first render
  • Without mode, old and new are in the DOM together. out-in waits for the old one to leave first. name can be dynamic. An element whose contents alone change needs a key or no transition happens
  • <TransitionGroup> renders no wrapper (specify one with tag), requires key (warning in development builds only), and has no mode
  • Reordering is FLIP through *-move. Nothing runs unless that class declares a transition on transform and the type-less automatic detection selects transition. The check works by inserting a clone into the DOM temporarily and reading the computed style
  • The order when combining with other built-ins is RouterView > Transition > KeepAlive > Suspense. Lifecycle comes after the boundary resolves. During a swap, the previous content stays even with mode="out-in", and the old one is removed when the boundary resolves
Related references