Transition Animation System: CSS Class State Machine, JS Hooks and FLIP Principle
Chapter 27: The Transition Animation System — CSS Class State Machines, JS Hooks, and the FLIP Principle
The core secret of the
<Transition>component:v-enter-fromandv-enter-activemust be added to the element in the same frame, before DOM insertion — otherwise the browser never sees a starting state and the animation jumps straight to its end point. This is the root cause of 95% of Vue animation bugs where "the animation just doesn't work."
The central question of this chapter: How does Vue's animation system precisely coordinate class-name switching between DOM operations and browser rendering frames?
After reading this chapter you will understand:
- The exact timing of when each of the 6 CSS class names is added and removed, and their relationship to
requestAnimationFrame/nextTick - Why JavaScript hooks must call
done(), and what happens if you forget - How
<TransitionGroup>'s FLIP algorithm uses CSStransformto animate element movement without actually changing DOM positions
Level 1 · What You Need to Know (1–3 Years Experience)
1.1 Six Class Names: The Complete Animation Lifecycle
The <Transition> component works by adding and removing CSS class names on elements at precise moments during entry and exit. There are 6 class names split into two groups:
Enter animation:
v-enter-from— Starting state of the enter animation; added before the element is inserted, removed on the first animation framev-enter-active— Present throughout the enter animation; defines thetransitionoranimationpropertyv-enter-to— Ending state; added on the first animation frame, removed when the animation completes
Leave animation:
v-leave-from— Starting state; added immediately when the leave animation is triggeredv-leave-active— Present throughout the leave animationv-leave-to— Ending state; added on the next frame, element removed after animation completes
The most common fade animation:
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
The logic here:
- On enter, transitions from
opacity: 0(v-enter-from) to the naturalopacity: 1 - On leave, transitions from
opacity: 1toopacity: 0(v-leave-to) v-enter-toandv-leave-fromusually don't need explicit styles since they represent the element's natural state
1.2 Basic Usage: Wrapping a Single Element
The <Transition> component can only wrap a single child element (or a single component):
<template>
<button @click="show = !show">Toggle</button>
<Transition name="fade">
<div v-if="show" class="box">Content</div>
</Transition>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
The name="fade" attribute changes the class name prefix from the default v- to fade-, allowing multiple different animation effects on the same page.
1.3 CSS animation vs CSS transition
v-enter-active can use either CSS transition or CSS animation:
Using CSS transition:
.slide-enter-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
Using CSS animation:
.bounce-enter-active {
animation: bounce-in 0.5s;
}
@keyframes bounce-in {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
Vue internally detects whether v-enter-active has a transition or animation property, and listens for the transitionend or animationend event to determine when the animation ends.
When both exist simultaneously, Vue uses whichever has the longer duration. You can also force a specific type with the type prop:
<Transition type="animation">
<!-- When both transition and animation exist, animation takes precedence -->
</Transition>
1.4 appear: Animate on Initial Render
By default, <Transition> only plays animations when elements toggle (show/hide), not on the initial render. Add the appear prop to change this:
<Transition name="fade" appear>
<div>This will also fade in when the page first loads</div>
</Transition>
Initial renders use dedicated class names: v-appear-from, v-appear-active, v-appear-to. If these are not defined, Vue falls back to the enter animation class names (v-enter-from, etc.).
1.5 Switching Between Multiple Elements: the mode Prop
When switching between two elements (for example, toggling button states), the leave and enter animations play simultaneously by default, which can cause both elements to briefly appear at once:
<Transition name="fade" mode="out-in">
<button v-if="isEditing" key="save">Save</button>
<button v-else key="edit">Edit</button>
</Transition>
mode="out-in" plays the leave animation first, and only begins the enter animation after it completes. mode="in-out" does the reverse.
Important: When switching between multiple elements, each must have a different key. Without distinct keys, Vue treats them as the same element updating its content and won't trigger the transition.
1.6 TransitionGroup: List Animations
<TransitionGroup> handles animations for v-for lists:
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* Movement animation */
.list-move {
transition: transform 0.3s ease;
}
/* Let leaving elements exit the flow so others can move into place */
.list-leave-active {
position: absolute;
}
</style>
The v-move class name (here list-move) is exclusive to <TransitionGroup> and handles element movement animations using the FLIP algorithm (covered in detail in Level 2).
Level 2 · How It Actually Works (3–5 Years Experience)
2.1 Precise Timing of Class Name Addition and Removal
Understanding Vue's animation system requires understanding exactly which frame each class name is added and removed. Here is the complete timeline for an enter animation:
Frame N (v-if="true" triggered)
│
├── Vue creates VNode, prepares DOM insertion
├── Adds v-enter-from (starting state: e.g. opacity: 0)
├── Adds v-enter-active (defines transition property)
├── Inserts DOM node into document
│ └── Browser has not yet rendered (still in same JS task)
│
Frame N+1 (next rendering frame, via requestAnimationFrame)
├── Adds v-enter-to (end state: e.g. opacity: 1)
├── Removes v-enter-from
│ └── Browser sees: opacity:0 → opacity:1 transition → animation starts
│
Animation end (transitionend event fires)
└── Removes v-enter-active and v-enter-to
└── Animation cleanup complete
Why must the DOM be inserted and class names added in the same frame?
The browser's rendering pipeline is: JS execution → Style calculation → Layout → Paint. Within a single JS task, even if you modify the DOM and styles multiple times, the browser performs a single style calculation after the task completes.
So Vue, within a single frame:
- Adds
v-enter-from(opacity: 0) - Inserts the DOM node
The browser sees: a new node with opacity: 0 style.
Then in the next frame (via requestAnimationFrame):
- Removes
v-enter-from - Adds
v-enter-to
The browser sees the style change from opacity: 0 to opacity: 1, triggering the CSS transition.
Failure scenario: If v-enter-from is removed in the same frame as the DOM insertion, the browser computes the style as opacity: 1 from the start — no starting point, no transition.
ASCII diagram — the complete enter animation state machine:
Trigger (v-if=true / v-show=true)
│
▼
┌────────────────────────────────────┐
│ Same Frame (Frame N) │
│ 1. Add v-enter-from │ opacity: 0
│ 2. Add v-enter-active │ transition: opacity 0.3s
│ 3. Insert DOM node │
└────────────────┬───────────────────┘
│ requestAnimationFrame
▼
┌────────────────────────────────────┐
│ Next Frame (Frame N+1) │
│ 4. Remove v-enter-from │ opacity: 0 → gone
│ 5. Add v-enter-to │ opacity: 1 → browser triggers transition
└────────────────┬───────────────────┘
│ transitionend event fires
▼
┌────────────────────────────────────┐
│ Animation End │
│ 6. Remove v-enter-active │
│ 7. Remove v-enter-to │
└────────────────────────────────────┘
2.2 Special Handling for Leave Animations
Leave animations have one important difference from enter animations: the element must remain in the DOM until the animation ends, rather than being removed immediately.
Trigger (v-if=false / v-show=false)
│
▼
┌────────────────────────────────────┐
│ Frame N │
│ 1. Add v-leave-from │ current state (opacity: 1)
│ 2. Add v-leave-active │ transition: opacity 0.3s
│ (element stays in DOM) │
└────────────────┬───────────────────┘
│ requestAnimationFrame
▼
┌────────────────────────────────────┐
│ Frame N+1 │
│ 3. Remove v-leave-from │
│ 4. Add v-leave-to │ opacity: 0 → triggers transition
└────────────────┬───────────────────┘
│ transitionend event fires
▼
┌────────────────────────────────────┐
│ Animation End │
│ 5. Remove element (or display:none│
│ 6. Remove v-leave-active │
│ 7. Remove v-leave-to │
└────────────────────────────────────┘
This is why the element is still in the DOM during the leave animation. Setting position: absolute prevents it from occupying layout space during this time — which is essential in <TransitionGroup>.
2.3 Duration Auto-Detection: How Vue Knows When an Animation Ends
Vue does not rely on hard-coded timing — it listens for CSS events:
// Pseudo-code, simplified from Vue 3 source: packages/runtime-dom/src/components/Transition.ts
function whenTransitionEnds(el, expectedType, resolve) {
const { type, timeout, propCount } = getTransitionInfo(el, expectedType);
if (!type) {
return resolve(); // No animation, complete immediately
}
const endEvent = type === 'transition' ? 'transitionend' : 'animationend';
let ended = 0;
const onEnd = (e) => {
if (e.target === el) {
ended++;
if (ended >= propCount) {
// All property transitions have completed
el.removeEventListener(endEvent, onEnd);
resolve();
}
}
};
el.addEventListener(endEvent, onEnd);
// Safety timeout: in case transitionend never fires
setTimeout(() => {
if (ended < propCount) {
resolve();
}
}, timeout + 1);
}
getTransitionInfo reads the element's computed styles (getComputedStyle(el)), parses transition-duration, animation-duration, and similar properties, and calculates the longest duration.
Multiple properties: If transitioning both opacity and transform, Vue waits for all properties' transitionend events before considering the animation complete.
2.4 JavaScript Hooks and the done() Mechanism
<Transition> provides complete JavaScript hooks for use with third-party animation libraries like GSAP:
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@enter-cancelled="onEnterCancelled"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
@leave-cancelled="onLeaveCancelled"
>
<div v-if="show">Content</div>
</Transition>
<script setup>
import gsap from 'gsap';
function onEnter(el, done) {
gsap.from(el, {
opacity: 0,
y: -20,
duration: 0.5,
onComplete: done // Must call done!
});
}
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
y: 20,
duration: 0.5,
onComplete: done // Must call done!
});
}
</script>
What done() does: It signals to Vue that the animation has completed. For the @enter and @leave hooks, done is the second parameter. If done() is not called:
@enter'sdonenot called: Vue thinks the enter animation is still running;@after-enternever fires@leave'sdonenot called: Vue thinks the leave animation is still running; the element is never removed from the DOM
When using JS hooks, add :css="false" to tell Vue to skip CSS transition detection and avoid interference:
<Transition :css="false" @enter="onEnter" @leave="onLeave">
Complete hook signatures:
| Hook | Parameters | Description |
|---|---|---|
before-enter |
(el) |
Before DOM insertion; element already has v-enter-from |
enter |
(el, done) |
After DOM insertion; must call done |
after-enter |
(el) |
Enter animation completed |
enter-cancelled |
(el) |
Enter animation interrupted (element hidden again) |
before-leave |
(el) |
Before leave animation starts |
leave |
(el, done) |
During leave animation; must call done |
after-leave |
(el) |
Leave animation completed, element removed |
leave-cancelled |
(el) |
Leave animation interrupted (element shown again) |
2.5 The FLIP Algorithm: TransitionGroup's Movement Animation Principle
FLIP stands for First-Last-Invert-Play, and it is the core technique for animating element movement. Regular CSS transitions can only animate changes to style values, but cannot directly animate DOM reordering (because DOM operations are instantaneous). FLIP works around this limitation:
The Four Steps of the FLIP Algorithm:
First (record initial positions)
│ getBoundingClientRect() records positions of all elements
│
Last (perform DOM operations)
│ Actually reorder the list (add, delete, reorder)
│ getBoundingClientRect() records new positions of all elements
│
Invert (calculate inverse transform)
│ For each moved element, compute:
│ deltaX = first.left - last.left
│ deltaY = first.top - last.top
│ Set transform: translate(deltaX, deltaY)
│ → makes element appear to still be in its original position
│ (This step is synchronous — browser hasn't rendered yet)
│
Play (play the animation)
│ Remove the forced transform (or animate it to translate(0, 0))
│ Browser renders: element moves from "original position" to "new position"
│ → produces a smooth visual animation
Vue's <TransitionGroup> has this FLIP logic built in:
// Pseudo-code, simplified from Vue 3 source: packages/runtime-dom/src/components/TransitionGroup.ts
// 1. First: record positions before update
children.forEach(child => {
const info = (child._transition = {});
info.oldPos = child.el.getBoundingClientRect();
});
// (Vue virtual DOM diff and actual DOM update happen here)
// 2. Last + Invert: after update, apply reverse transform to moved elements
nextTick(() => {
children.forEach(child => {
const c = child._transition;
const newPos = child.el.getBoundingClientRect();
const dx = c.oldPos.left - newPos.left;
const dy = c.oldPos.top - newPos.top;
if (dx || dy) {
// Invert: make element appear to be at its original position
child.el.style.transform = `translate(${dx}px, ${dy}px)`;
child.el.style.transitionDuration = '0s'; // disable transition temporarily
}
});
// 3. Play: force browser reflow, then clear transforms
document.body.offsetHeight; // force reflow (reading layout info)
children.forEach(child => {
if (child.el.style.transform) {
// Add v-move class (defines transition), then clear transform
child.el.classList.add('v-move');
child.el.style.transform = '';
child.el.style.transitionDuration = '';
}
});
});
Why is position: absolute needed?
When a list item is leaving, it still occupies DOM space, which affects the "Last" position calculation for other elements. Setting position: absolute removes the leaving element from the document flow, allowing other elements to correctly move to their target positions.
.list-leave-active {
position: absolute; /* Critical: leaving elements leave the flow */
}
.list-move {
transition: transform 0.3s ease;
}
2.6 Route Transition Animations
Implementing page transition animations with Vue Router:
<!-- App.vue -->
<template>
<RouterView v-slot="{ Component, route }">
<Transition :name="route.meta.transition || 'fade'" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</template>
<style>
/* Forward navigation: new page enters from the right */
.slide-left-enter-from { transform: translateX(100%); }
.slide-left-leave-to { transform: translateX(-100%); }
/* Backward navigation: new page enters from the left */
.slide-right-enter-from { transform: translateX(-100%); }
.slide-right-leave-to { transform: translateX(100%); }
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s ease;
}
</style>
Level 3 · Design Documents and Source Code (Senior Developers)
3.1 Transition Component Source Structure
Vue 3's Transition implementation is spread across two packages:
packages/runtime-core/src/components/BaseTransition.ts— Platform-agnostic core logic (state machine, hook scheduling)packages/runtime-dom/src/components/Transition.ts— DOM-specific logic (CSS class manipulation)
Core of BaseTransition (packages/runtime-core/src/components/BaseTransition.ts):
// Simplified enter animation scheduling logic
function performEnter(el: Element, hooks: TransitionHooks) {
const { beforeEnter, enter, afterEnter, enterCancelled } = hooks;
const resolve = (cancelled?: boolean) => {
if (cancelled) {
enterCancelled(el);
} else {
afterEnter(el);
}
if (el._enterCb) {
el._enterCb = null;
}
};
// Prevent duplicate execution
if (el._enterCb) {
el._enterCb(true /* cancelled */);
}
el._enterCb = resolve;
if (beforeEnter) beforeEnter(el);
// Execute in next frame (this is where requestAnimationFrame is called)
nextFrame(() => {
if (el._enterCb) {
if (enter) {
enter(el, resolve);
} else {
resolve();
}
}
});
}
DOM-layer class name operations (packages/runtime-dom/src/components/Transition.ts):
const enterHooks: TransitionHooks = {
beforeEnter(el) {
// Add v-enter-from and v-enter-active (Frame N)
addTransitionClass(el, enterFromClass); // v-enter-from
addTransitionClass(el, enterActiveClass); // v-enter-active
},
enter(el, done) {
// This runs in Frame N+1 (inside nextFrame callback)
// Remove v-enter-from, add v-enter-to
removeTransitionClass(el, enterFromClass);
addTransitionClass(el, enterToClass);
if (!hasExplicitCallback(done)) {
// No explicit JS done callback — use CSS events
whenTransitionEnds(el, type, () => {
removeTransitionClass(el, enterToClass);
removeTransitionClass(el, enterActiveClass);
done();
});
}
},
afterEnter(el) {
removeTransitionClass(el, enterToClass);
removeTransitionClass(el, enterActiveClass);
}
};
3.2 The nextFrame Implementation
Vue internally uses double-nested requestAnimationFrame to ensure execution in the next rendering frame:
// packages/runtime-dom/src/components/Transition.ts
const raf = window.requestAnimationFrame
? window.requestAnimationFrame.bind(window)
: setTimeout;
function nextFrame(fn: () => void) {
raf(() => {
raf(fn); // Double RAF to ensure execution after style calculation
});
}
Why double RAF?
A single requestAnimationFrame callback can still execute before the browser processes styles (depending on browser implementation). Double RAF ensures:
- First RAF: browser completes style calculation and layout for the current frame
- Second RAF: executes at the start of the next frame, when DOM and styles are stable
Note: Some browsers (Chrome 64+) have precise enough RAF implementations that single RAF also works. But double RAF is the safer pattern, and Vue has retained it.
3.3 Performance Optimization: GPU Compositing Layers
The key to animation performance is avoiding Layout (reflow) and Paint (repaint), triggering only Composite:
Properties that only trigger Composite (GPU-accelerated):
transform: translate/scale/rotateopacity
Properties that trigger Layout (performance killers — avoid animating these):
width,height,margin,paddingtop,left(usetransforminstead for absolutely positioned elements)font-size
Best practice: Use will-change to notify the browser in advance:
.animated-element {
will-change: transform, opacity;
/* Browser creates a separate compositing layer for this element */
}
Warning about will-change overuse: Every compositing layer consumes GPU memory (approximately 4 × width × height bytes). Don't apply it to all elements. Only use it on elements about to animate, and remove it afterward:
el.addEventListener('mouseenter', () => {
el.style.willChange = 'transform';
});
el.addEventListener('animationend', () => {
el.style.willChange = 'auto'; // release after animation
});
Level 4 · Edge Cases and Traps (For Everyone)
Trap 1: Modifying CSS Class Names in the Same Frame Causes Animation to Skip
Broken code — manually removing the starting class name in the same frame:
// Wrong! Animation won't work
function startAnimation(el) {
el.classList.add('fade-enter-from');
el.classList.add('fade-enter-active');
el.style.display = 'block';
// Wrong: removing fade-enter-from in the same frame
el.classList.remove('fade-enter-from');
el.classList.add('fade-enter-to');
// Browser sees: element immediately in fade-enter-to state — no animation
}
Correct approach:
function startAnimation(el) {
el.classList.add('fade-enter-from');
el.classList.add('fade-enter-active');
el.style.display = 'block';
// Force reflow so the browser "sees" the initial state
el.offsetHeight; // reading any layout property forces reflow
// Or use requestAnimationFrame
requestAnimationFrame(() => {
el.classList.remove('fade-enter-from');
el.classList.add('fade-enter-to');
});
}
Trap 2: Forgetting to Call done() in @enter/@leave Hooks — Element Never Disappears
Broken code:
<Transition @leave="onLeave">
<div v-if="show">Content</div>
</Transition>
<script setup>
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
duration: 0.5,
onComplete: () => {
// Forgot to call done()!
console.log('Animation complete');
}
});
}
</script>
Symptom: The leave animation plays and the element's opacity reaches 0, but the element still occupies DOM space (Vue is waiting for done() before removing the element).
Correct code:
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
duration: 0.5,
onComplete: done // Pass done directly as the callback
});
}
Trap 3: v-for List Without Key, or Incorrect Key Usage
Broken scenario:
<!-- Wrong: using index as key -->
<TransitionGroup name="list">
<div v-for="(item, index) in items" :key="index">
{{ item.name }}
</div>
</TransitionGroup>
When inserting an element at the beginning of the list, all element indices change. Vue treats this as all elements "updating" rather than "being added," and the FLIP animation produces incorrect results.
Correct approach: Use stable unique IDs as keys:
<TransitionGroup name="list">
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
</TransitionGroup>
Trap 4: Forgetting position: absolute in TransitionGroup Causes FLIP Jitter
Broken scenario — list items don't exit the document flow during leave:
/* Missing position: absolute */
.list-leave-active {
transition: opacity 0.3s;
}
.list-leave-to {
opacity: 0;
}
Symptom: When deleting a list item, the item slowly fades out, but during its fade it still occupies layout space, causing other elements' "Last" positions to be calculated incorrectly. The FLIP animation first jumps to the wrong position before moving to the correct one — visually this looks like jitter.
Correct code:
.list-leave-active {
position: absolute; /* Exit the document flow */
transition: opacity 0.3s;
width: 100%; /* Prevent width collapse from absolute positioning */
}
.list-leave-to {
opacity: 0;
}
.list-move {
transition: transform 0.3s ease;
}
Trap 5: mode="out-in" Combined with keep-alive Can Freeze Animations
Broken scenario:
<!-- Wrong: using mode="out-in" around keep-alive can cause issues -->
<Transition mode="out-in">
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
</Transition>
Components cached by <KeepAlive> don't truly unmount when they "leave" — instead they trigger the deactivated hook. This can conflict with mode="out-in"'s "wait for leave to complete" logic.
Correct approach: Place <Transition> inside <KeepAlive>:
<KeepAlive>
<Transition mode="out-in">
<component :is="currentComponent" />
</Transition>
</KeepAlive>
Chapter Summary
-
The state machine timing is foundational:
v-enter-fromandv-enter-activemust be added in the same frame before DOM insertion; only in the next frame (viarequestAnimationFrame) does Vue switch tov-enter-to. This precise sequencing is what makes animations trigger at all. -
done() is non-negotiable: When using JavaScript hooks, the second parameter
donein@enterand@leavemust be called. Without it, Vue's state machine never advances — the element either never completes its enter animation or is never removed from the DOM. -
FLIP is the foundation of TransitionGroup: Record positions (First) → perform DOM operations (Last) → calculate reverse offset (Invert) → clear offset to trigger transition (Play). This technique produces smooth visual movement without actually moving DOM positions.
-
Animate only transform and opacity for performance: These two properties only trigger GPU compositing and avoid Layout and Paint — they are the gold standard for animation performance.
will-changecan pre-create compositing layers but must not be overused. -
TransitionGroup leaving elements must use position: absolute: This removes leaving elements from the document flow so that other elements can correctly calculate their FLIP "Last" positions, preventing movement animation jitter.