| |

Vue.js 65 🟢 Built-in Animation Wrappers ( and )

⏱️ Estimated read: 15 min | |

Vue’s transition system is not a separate animation library. It is a set of built-in components that wrap the DOM insertion and removal operations that Vue already performs, and apply CSS classes or JavaScript hooks at precise moments during those operations. The <Transition> component handles a single element or component. The <TransitionGroup> component handles a list, adding the critical capability that <Transition> lacks: animating the movement of items that are not entering or leaving but are shifting position because a sibling was added or removed . Neither component animates anything by itself. They provide the timing hooks and the class names; the actual animation is defined in CSS or in JavaScript.

The mechanism is built on a precise sequence of class applications. For an enter transition, Vue adds the -enter-from class, inserts the element, adds the -enter-active class, removes the -enter-from class on the next frame to trigger the transition, and removes the -enter-active class when the transition ends. The leave transition follows the same pattern in reverse . This sequence is what makes CSS transitions work: the browser needs a frame between the “from” state and the “to” state to calculate the interpolation. Vue’s transition system handles that frame timing automatically.

Key point: <Transition> animates a single element or component; <TransitionGroup> animates a list and adds move transitions via the FLIP technique. Both apply CSS classes (-enter-from, -enter-active, -enter-to, -leave-from, -leave-active, -leave-to) at precise moments, and both support JavaScript hooks (@before-enter, @enter, @after-enter, etc.) for animation libraries. <TransitionGroup> requires a unique key on every child and does not support mode.


Why the transition system is class-based

The CSS-first problem. Vue’s transition system was designed around CSS transitions and animations, not around a JavaScript animation engine. The class-based approach means the animation is defined in the stylesheet, where it belongs, and the component only manages the timing. A fade transition is three CSS rules: .fade-enter-active and .fade-leave-active define the transition duration and easing, and .fade-enter-from and .fade-leave-to define the starting state (opacity 0) . The component does not know or care what the animation is; it only knows when to add and remove the classes.

The JavaScript hook problem. CSS cannot cover every animation. A staggered list where each item enters with a different delay, a transition that needs to be cancelled mid-flight, or an animation driven by a library like GSAP or Anime.js requires JavaScript. The hooks provide the entry points: @before-enter runs before the element is inserted, @enter runs after insertion and receives a done callback, and @after-enter runs when the transition completes . When the hooks are used without CSS, the :css="false" prop tells Vue to skip CSS detection and rely entirely on the done callback to signal completion .

The mode problem. When transitioning between two elements with v-if and v-else, both elements are present during the transition by default, which causes a visual overlap. The mode prop controls the sequence: "out-in" waits for the old element to leave before the new one enters, and "in-out" does the opposite . This is the difference between a smooth crossfade and a jarring double-render, and it is the most common configuration issue in <Transition>.

The TransitionGroup move problem. A list where items are inserted or removed has a visual flaw that <Transition> cannot solve: the surrounding items “jump” into their new positions instantly. The <TransitionGroup> solves this with the -move class, which is applied to elements that are changing position but not entering or leaving. The class uses the FLIP technique (First, Last, Invert, Play) to animate from the old position to the new one . The critical CSS rule is .list-leave-active { position: absolute; }, which removes the leaving element from the layout flow so the move animation can be calculated correctly .


a. The <Transition> component: a single element

The <Transition> component wraps a single element or component. The wrapped element must be conditional (v-if, v-show, or a dynamic component with :is), because the transition is triggered by the element entering or leaving the DOM. The name prop sets the class prefix.

<script setup>
import { ref } from 'vue'

// The show flag controls the element's presence
const show = ref(false)
</script>

<template>
  <!-- The Transition wraps the conditional element -->
  <!-- name="fade" sets the class prefix to .fade-* -->
  <Transition name="fade">
    <p v-if="show">Hello</p>
  </Transition>

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

<style>
/* The active classes define the transition itself */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

/* The from and to classes define the start and end states */
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

The name prop is the only configuration required for a simple CSS transition. When the name is fade, Vue applies .fade-enter-from, .fade-enter-active, .fade-enter-to, .fade-leave-from, .fade-leave-active, and .fade-leave-to at the appropriate moments. The .fade-enter-active class is applied for the entire enter phase, and it is where the transition property lives. The .fade-enter-from class is applied for one frame at the start, and it defines the initial state (opacity 0). When it is removed and the element reverts to its natural state, the CSS transition runs .

The appear prop applies the transition on the initial render, not just on subsequent toggles. Without it, the element appears instantly when the component mounts. With it, the enter transition runs on mount .

The mode prop is essential when transitioning between multiple elements. Without it, both elements are rendered simultaneously during the transition:

<template>
  <!-- out-in: old element leaves, then new element enters -->
  <Transition name="fade" mode="out-in">
    <component :is="currentView" :key="currentView" />
  </Transition>
</template>

The key on the dynamic component is what forces Vue to treat the two components as different elements. Without the key, Vue reuses the same element and the transition does not fire .


b. The <TransitionGroup> component: a list

The <TransitionGroup> component wraps a v-for list. It supports the same props, CSS classes, and JavaScript hooks as <Transition>, with four differences: it renders a wrapper element only if the tag prop is provided, it does not support mode, every child must have a unique key, and the CSS classes apply to the individual children rather than the container .

<script setup>
import { ref } from 'vue'

// The list of items
const items = ref(['A', 'B', 'C'])
</script>

<template>
  <!-- tag="ul" renders a <ul> wrapper -->
  <!-- name="list" sets the class prefix to .list-* -->
  <TransitionGroup name="list" tag="ul">
    <!-- Every child must have a unique key -->
    <li v-for="item in items" :key="item">
      {{ item }}
    </li>
  </TransitionGroup>
</template>

<style>
/* Enter and leave transitions for individual items */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

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

/* The move class is applied to items changing position */
.list-move {
  transition: transform 0.5s ease;
}

/* Remove leaving items from layout flow for correct move calculation */
.list-leave-active {
  position: absolute;
}
</style>

The .list-move class is the key to smooth list animations. When an item is removed, the leaving item gets the .list-leave-active class (which includes position: absolute), and the remaining items get the .list-move class. The browser calculates the old position of each moving item, applies the new position, and then transitions from the old to the new using the .list-move transition . Without the .list-leave-active { position: absolute; } rule, the leaving item still occupies space, and the move animation calculates the wrong positions.

The tag prop determines what wrapper element is rendered. Without it, <TransitionGroup> renders a fragment (no wrapper), which is useful when the list is inside an existing container. With tag="ul", it renders a <ul> and the children are the <li> elements .


c. JavaScript hooks and the done callback

The CSS classes cover the common cases, but the hooks are where the transition system becomes a general-purpose animation coordinator. The hooks are the same for <Transition> and <TransitionGroup>, and they receive the element and, for @enter and @leave, a done callback.

<script setup>
import gsap from 'gsap'

// The beforeEnter hook runs before the element is inserted
function onBeforeEnter(el) {
  // Set the initial state directly on the element
  el.style.opacity = 0
}

// The enter hook runs after insertion
// The done callback signals that the transition is complete
function onEnter(el, done) {
  gsap.to(el, {
    opacity: 1,
    duration: 0.5,
    // The onComplete callback calls done to tell Vue the transition is finished
    onComplete: done,
  })
}

// The leave hook runs when the element is leaving
function onLeave(el, done) {
  gsap.to(el, {
    opacity: 0,
    duration: 0.5,
    onComplete: done,
  })
}
</script>

<template>
  <!-- :css="false" tells Vue to skip CSS detection -->
  <Transition
    :css="false"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @leave="onLeave"
  >
    <div v-if="show">Animated content</div>
  </Transition>
</template>

The :css="false" prop is required when using JavaScript-only transitions. Without it, Vue applies the CSS classes and waits for a transitionend or animationend event that never fires, and the done callback is called but the CSS classes interfere . With :css="false", Vue relies entirely on the done callback to know when the transition is finished.

The @before-enter hook is the place to set the initial state. The @enter hook is the place to run the animation. The @after-enter hook is for cleanup. The @enter-cancelled hook fires if the enter transition is interrupted before it completes, which is useful for cancelling animations that are no longer relevant .

The done callback is the critical piece. If it is not called, Vue does not know the transition is finished and the element remains in the transition state indefinitely. The pattern is to call done in the animation library’s onComplete callback, which guarantees that Vue is notified only after the animation actually finishes .


Complete Example Session

The following session builds a list with enter, leave, and move transitions, and a modal with an out-in transition between two views.

<!-- ============================================
     PART 1: THE LIST WITH MOVE TRANSITIONS
     ============================================ -->
<script setup>
import { ref } from 'vue'

// The list items and a counter for adding new ones
const items = ref([1, 2, 3])
const nextId = ref(4)

// Add a new item to the end of the list
function add() {
  items.value.push(nextId.value++)
}

// Remove the last item
function remove() {
  items.value.pop()
}

// Shuffle the list to demonstrate move transitions
function shuffle() {
  items.value = [...items.value].sort(() => Math.random() - 0.5)
}
</script>

<template>
  <div>
    <button @click="add">Add</button>
    <button @click="remove">Remove</button>
    <button @click="shuffle">Shuffle</button>

    <!-- TransitionGroup animates the list -->
    <TransitionGroup name="list" tag="ul">
      <!-- Every item needs a unique key -->
      <li v-for="item in items" :key="item">{{ item }}</li>
    </TransitionGroup>
  </div>
</template>

<style>
/* Enter and leave transitions */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

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

/* Move transition for items changing position */
.list-move {
  transition: transform 0.5s ease;
}

/* Remove leaving items from layout flow */
.list-leave-active {
  position: absolute;
}
</style>
<!-- ============================================
     PART 2: THE VIEW SWITCHER WITH MODE
     ============================================ -->
<script setup>
import { ref, computed } from 'vue'
import ViewA from './ViewA.vue'
import ViewB from './ViewB.vue'

// The current view name
const view = ref('A')

// The component to render based on the view name
const currentComponent = computed(() =>
  view.value === 'A' ? ViewA : ViewB
)
</script>

<template>
  <div>
    <button @click="view = 'A'">View A</button>
    <button @click="view = 'B'">View B</button>

    <!-- mode="out-in" waits for the old view to leave -->
    <!-- The key forces Vue to treat the components as different -->
    <Transition name="fade" mode="out-in">
      <component :is="currentComponent" :key="view" />
    </Transition>
  </div>
</template>

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

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

The two parts cover the list with enter, leave, and move transitions, and the view switcher with out-in mode. What the session demonstrates is that the list’s move transition depends on the position: absolute rule for the leaving item, and the view switcher’s mode depends on the key to force the component swap.


Quick Reference

<Transition> Props

PropDefaultPurpose
name'v'Class prefix
appearfalseTransition on initial render
mode'default''out-in', 'in-out', 'default'
csstrueApply CSS transition classes
typeauto'transition' or 'animation'
durationautoExplicit duration in ms

CSS Classes

ClassApplied When
-enter-fromBefore enter, removed next frame
-enter-activeDuring entire enter
-enter-toAfter enter starts, removed at end
-leave-fromBefore leave
-leave-activeDuring entire leave
-leave-toAfter leave starts, removed at end

JavaScript Hooks

HookReceives
@before-enterel
@enterel, done
@after-enterel
@leaveel, done
@after-leaveel
@enter-cancelledel

<TransitionGroup> Differences

Aspect<Transition><TransitionGroup>
TargetSingle elementList of elements
tag propNoYes
mode propYesNo
key requiredNoYes
Move classNoYes

Best Practices

✅ Do This:

<!-- Use mode="out-in" for view switching -->
<Transition name="fade" mode="out-in">
  <component :is="view" :key="view" />
</Transition>

<!-- Use position: absolute on leaving items -->
.list-leave-active { position: absolute; }

<!-- Use :css="false" for JavaScript-only transitions -->
<Transition :css="false" @enter="onEnter" @leave="onLeave">

<!-- Use unique keys in TransitionGroup -->
<li v-for="item in items" :key="item.id">

❌ Don’t Do This:

<!-- Forget the key in mode transitions -->
<component :is="view" /> <!-- no transition -->

<!-- Forget position: absolute on leaving items -->
.list-leave-active { /* missing */ } <!-- wrong move animation -->

<!-- Use CSS classes with JavaScript transitions -->
:css="false" <!-- must be set -->

<!-- Use mode on TransitionGroup -->
<TransitionGroup mode="out-in"> <!-- not supported -->

Common Pitfalls

PitfallWhy It HappensFix
Transition does not fireElement not conditionalUse v-if or v-show
Both elements visibleNo modeAdd mode="out-in"
Move animation wrongLeaving item still in flowposition: absolute
List transition brokenNo unique keysAdd :key
JS transition hangsdone not calledCall done on complete

Real-World Examples

1. Fade transition

<Transition name="fade"><div v-if="show" /></Transition>

2. Mode out-in

<Transition name="fade" mode="out-in"><component :is="view" :key="view" /></Transition>

3. Appear

<Transition name="fade" appear><div v-if="show" /></Transition>

4. List enter/leave

<TransitionGroup name="list" tag="ul"><li v-for="i in items" :key="i" /></TransitionGroup>

5. Move transition

.list-move { transition: transform 0.5s ease; }

6. Leave absolute

.list-leave-active { position: absolute; }

7. JS enter hook

<Transition :css="false" @enter="(el, done) => gsap.to(el, { opacity: 1, onComplete: done })">

8. Cancel hook

<Transition @enter-cancelled="cancelAnimation">

9. Custom class

<Transition enter-active-class="animate__animated animate__tada">

10. Stagger with data-index

<li v-for="(item, i) in items" :key="item" :data-index="i" />

Visual

Transition Class Sequence

┌──────────────────────────────────────────────┐
│  ENTER                                       │
│  -enter-from added                          │
│  element inserted                           │
│  -enter-active added                        │
│  next frame: -enter-from removed            │
│  transition runs                            │
│  transitionend: -enter-active removed       │
│                                              │
│  LEAVE                                       │
│  -leave-from added                          │
│  next frame: -leave-from removed            │
│  -leave-active added                        │
│  transition runs                            │
│  transitionend: element removed             │
└──────────────────────────────────────────────┘

TransitionGroup Move

┌──────────────────────────────────────────────┐
│  Item B removed                             │
│         │                                    │
│         ▼                                    │
│  B gets -leave-active (position: absolute)  │
│         │                                    │
│         ▼                                    │
│  Items A and C get -move                    │
│         │                                    │
│         ▼                                    │
│  FLIP: record old position, apply new,      │
│  transition from old to new                 │
└──────────────────────────────────────────────┘

Mode Comparison

┌──────────────────────────────────────────────┐
│  default: both visible during transition    │
│  out-in: old leaves, then new enters       │
│  in-out: new enters, then old leaves       │
└──────────────────────────────────────────────┘

Summary

ItemValue
Single element<Transition>
List<TransitionGroup>
CSS classes-enter-from, -enter-active, -enter-to, etc.
JS hooks@before-enter, @enter(el, done), @after-enter
Modeout-in, in-out, default
Move class-move (TransitionGroup)
Key requirementTransitionGroup children must be keyed
done callbackRequired for JS-only transitions

Key takeaways:

  • <Transition> wraps a single element; <TransitionGroup> wraps a list. Both apply CSS classes at precise moments and support JavaScript hooks, but <TransitionGroup> adds the move transition and requires unique keys on every child .
  • The CSS classes are the timing contract. The -active class defines the transition, the -from class defines the start state, and the -to class defines the end state. Vue applies and removes them in a specific sequence to trigger the browser’s transition engine .
  • mode is required for view switching. Without out-in, both elements are visible during the transition. The key on the dynamic component is what forces Vue to treat the two views as different elements .
  • The move transition needs position: absolute on leaving items. Without it, the leaving item still occupies space, and the FLIP calculation for the surrounding items is wrong .
  • JavaScript hooks are for animations that CSS cannot express. The @enter and @leave hooks receive a done callback, and :css="false" tells Vue to rely on that callback instead of CSS events .
  • The appear prop applies the transition on mount. Without it, the element appears instantly on initial render and only transitions on subsequent toggles .

Remember: Vue’s transition components do not animate anything. They provide the timing hooks and the class names, and the animation is defined in CSS or in JavaScript. The <Transition> handles a single element; the <TransitionGroup> handles a list and adds the move transition that makes list reordering smooth. The mode prop controls the sequence when switching between elements, and the key is what makes the switch possible. The done callback is the contract for JavaScript animations, and :css="false" is what prevents the CSS classes from interfering with them. The system is declarative, but the animation itself is not—it is still CSS or JavaScript, and the component only decides when to apply it.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!