Chapter 27

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-from and v-enter-active must 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:


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:

Leave animation:

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:

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 executionStyle calculationLayoutPaint. 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:

  1. Adds v-enter-from (opacity: 0)
  2. Inserts the DOM node

The browser sees: a new node with opacity: 0 style.

Then in the next frame (via requestAnimationFrame):

  1. Removes v-enter-from
  2. 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:

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:

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:

  1. First RAF: browser completes style calculation and layout for the current frame
  2. 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):

Properties that trigger Layout (performance killers — avoid animating these):

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

  1. The state machine timing is foundational: v-enter-from and v-enter-active must be added in the same frame before DOM insertion; only in the next frame (via requestAnimationFrame) does Vue switch to v-enter-to. This precise sequencing is what makes animations trigger at all.

  2. done() is non-negotiable: When using JavaScript hooks, the second parameter done in @enter and @leave must 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.

  3. 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.

  4. 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-change can pre-create compositing layers but must not be overused.

  5. 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.

Rate this chapter
4.8  / 5  (4 ratings)

💬 Comments