Transition y TransitionGroup

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

Transition con CSS

El componente Transition de Vue aplica animaciones de entrada y salida cuando un elemento o componente se inserta o elimina del DOM. Funciona con v-if, v-show, componentes dinámicos con <component :is> y la transición del componente raíz de una ruta.

Transition envuelve un único elemento o componente hijo y aplica clases CSS en momentos específicos del ciclo de entrada/salida.

Clases de transición CSS

Vue genera automáticamente seis clases CSS durante una transición:

Entrada:

  • v-enter-from: Estado inicial antes de entrar. Se aplica un frame antes de la inserción y se elimina un frame después.
  • v-enter-active: Estado activo durante toda la entrada. Se aplica durante toda la fase de entrada. Aquí se definen transition o animation.
  • v-enter-to: Estado final de la entrada. Se aplica un frame después de la inserción y se elimina cuando la transición termina.

Salida:

  • v-leave-from: Estado inicial de la salida. Se aplica inmediatamente cuando se dispara la salida.
  • v-leave-active: Estado activo durante toda la salida. Similar a v-enter-active para la fase de salida.
  • v-leave-to: Estado final de la salida. Se aplica un frame después del inicio de la salida.

Ejemplo básico:

<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)
</script>

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

  <Transition>
    <p v-if="show">Este texto aparece y desaparece con animación</p>
  </Transition>
</template>

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

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

Transiciones con nombre

Cuando se usa la prop name, las clases usan ese nombre como prefijo en lugar de v-:

<template>
  <Transition name="slide-fade">
    <div v-if="show" class="panel">Contenido del panel</div>
  </Transition>
</template>

<style>
.slide-fade-enter-active {
  transition: all 0.3s ease-out;
}

.slide-fade-leave-active {
  transition: all 0.2s ease-in;
}

.slide-fade-enter-from {
  transform: translateX(-20px);
  opacity: 0;
}

.slide-fade-leave-to {
  transform: translateX(20px);
  opacity: 0;
}
</style>

Con el nombre slide-fade, las clases generadas son slide-fade-enter-from, slide-fade-enter-active, slide-fade-enter-to, slide-fade-leave-from, slide-fade-leave-active y slide-fade-leave-to.

Clases de transición personalizadas

Las props de clases personalizadas permiten usar clases de librerías externas como Animate.css:

<template>
  <Transition
    enter-active-class="animate__animated animate__fadeInUp"
    leave-active-class="animate__animated animate__fadeOutDown"
  >
    <div v-if="show" class="card">Contenido animado</div>
  </Transition>
</template>

Las props disponibles son: enter-from-class, enter-active-class, enter-to-class, leave-from-class, leave-active-class y leave-to-class. Esto sobrescribe las clases generadas automáticamente por Vue.

Modos de transición

Cuando se alterna entre dos elementos, ambos se animan simultáneamente por defecto. La prop mode controla el orden:

  • mode="out-in": El elemento actual sale primero y luego el nuevo entra. Este es el modo más usado.
  • mode="in-out": El nuevo elemento entra primero y luego el actual sale.
<script setup lang="ts">
import { ref } from 'vue'

const isEditing = ref(false)
</script>

<template>
  <Transition name="fade" mode="out-in">
    <div v-if="isEditing" key="edit">
      <input type="text" placeholder="Editando..." />
      <button @click="isEditing = false">Guardar</button>
    </div>
    <div v-else key="display">
      <p>Contenido en modo lectura</p>
      <button @click="isEditing = true">Editar</button>
    </div>
  </Transition>
</template>

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

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

El atributo key es necesario para que Vue distinga entre los dos elementos y aplique la transición correctamente.

JavaScript hooks

Transition emite eventos JavaScript en cada fase de la animación, permitiendo crear animaciones programáticas con librerías como GSAP o Web Animations API:

<script setup lang="ts">
import { ref } from 'vue'

const show = ref(true)

function onBeforeEnter(el: Element) {
  const htmlEl = el as HTMLElement
  htmlEl.style.opacity = '0'
  htmlEl.style.transform = 'translateY(30px)'
}

function onEnter(el: Element, done: () => void) {
  const htmlEl = el as HTMLElement
  htmlEl.animate(
    [
      { opacity: 0, transform: 'translateY(30px)' },
      { opacity: 1, transform: 'translateY(0)' }
    ],
    { duration: 400, easing: 'ease-out' }
  ).onfinish = done
}

function onLeave(el: Element, done: () => void) {
  const htmlEl = el as HTMLElement
  htmlEl.animate(
    [
      { opacity: 1, transform: 'scale(1)' },
      { opacity: 0, transform: 'scale(0.9)' }
    ],
    { duration: 300, easing: 'ease-in' }
  ).onfinish = done
}
</script>

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

  <Transition
    :css="false"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @leave="onLeave"
  >
    <div v-if="show" class="animated-box">Contenido animado con JS</div>
  </Transition>
</template>

Los hooks disponibles son: @before-enter, @enter, @after-enter, @enter-cancelled, @before-leave, @leave, @after-leave y @leave-cancelled. La prop :css="false" indica a Vue que no aplique clases CSS automáticamente y confíe en los hooks JavaScript.

La función done del hook @enter y @leave debe llamarse para indicar que la animación ha terminado.

Animación inicial con appear

La prop appear aplica la transición cuando el componente se monta por primera vez:

<template>
  <Transition name="fade" appear>
    <h1>Este título se anima al cargar la página</h1>
  </Transition>
</template>

<style>
.fade-enter-active {
  transition: opacity 0.6s ease;
}

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

Sin appear, la transición solo se aplica cuando el elemento se inserta o elimina dinámicamente después del montaje.

TransitionGroup para listas

TransitionGroup aplica transiciones a los elementos de una lista renderizada con v-for. A diferencia de Transition:

  • Renderiza un elemento wrapper real en el DOM (por defecto <span>, configurable con la prop tag)
  • No soporta la prop mode
  • Cada elemento hijo debe tener un atributo :key único
  • Las clases de transición se aplican a cada elemento individual de la lista
<script setup lang="ts">
import { ref } from 'vue'

interface ListItem {
  id: number
  text: string
}

const items = ref<ListItem[]>([
  { id: 1, text: 'Primer elemento' },
  { id: 2, text: 'Segundo elemento' },
  { id: 3, text: 'Tercer elemento' }
])

let nextId = 4

function addItem() {
  const index = Math.floor(Math.random() * (items.value.length + 1))
  items.value.splice(index, 0, {
    id: nextId++,
    text: `Elemento ${nextId - 1}`
  })
}

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

<template>
  <button @click="addItem">Añadir</button>

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

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

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

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

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

.list-move {
  transition: transform 0.4s ease;
}
</style>

Transiciones de movimiento (FLIP)

La clase *-move es exclusiva de TransitionGroup y se aplica cuando los elementos cambian de posición en la lista. Vue usa la técnica FLIP (First, Last, Invert, Play) internamente:

  1. First: Registra la posición actual de cada elemento
  2. Last: Calcula la posición final después del cambio
  3. Invert: Aplica un transform para que los elementos parezcan estar en su posición original
  4. Play: Elimina el transform con una transición CSS, animando el movimiento

Para que la animación de movimiento funcione correctamente cuando un elemento sale, este debe sacarse del flujo normal con position: absolute en la clase *-leave-active. Esto permite que los elementos restantes se reposicionen inmediatamente y la transición de movimiento se aplique.

Ejemplo completo: lista animada de tareas

<script setup lang="ts">
import { ref, computed } from 'vue'

interface Todo {
  id: number
  text: string
  completed: boolean
}

const newTodoText = ref('')
const todos = ref<Todo[]>([])
let nextId = 1

const sortedTodos = computed(() =>
  [...todos.value].sort((a, b) => Number(a.completed) - Number(b.completed))
)

function addTodo() {
  if (newTodoText.value.trim() === '') return
  todos.value.push({
    id: nextId++,
    text: newTodoText.value.trim(),
    completed: false
  })
  newTodoText.value = ''
}

function removeTodo(id: number) {
  todos.value = todos.value.filter(t => t.id !== id)
}

function toggleTodo(id: number) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) todo.completed = !todo.completed
}
</script>

<template>
  <div class="todo-app">
    <form @submit.prevent="addTodo" class="add-form">
      <input v-model="newTodoText" placeholder="Nueva tarea..." />
      <button type="submit">Añadir</button>
    </form>

    <TransitionGroup name="todo" tag="ul" class="todo-list">
      <li
        v-for="todo in sortedTodos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        <input
          type="checkbox"
          :checked="todo.completed"
          @change="toggleTodo(todo.id)"
        />
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)" class="delete-btn">
          &times;
        </button>
      </li>
    </TransitionGroup>
  </div>
</template>

<style>
.todo-list {
  list-style: none;
  padding: 0;
  position: relative;
}

.todo-list li {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: white;
  border: 1px solid #eee;
  border-radius: 6px;
  margin-bottom: 8px;
}

.todo-list li.completed span {
  text-decoration: line-through;
  color: #999;
}

.todo-enter-active {
  transition: all 0.3s ease-out;
}

.todo-leave-active {
  transition: all 0.3s ease-in;
  position: absolute;
  width: 100%;
}

.todo-enter-from {
  opacity: 0;
  transform: translateY(-20px);
}

.todo-leave-to {
  opacity: 0;
  transform: translateX(40px);
}

.todo-move {
  transition: transform 0.4s ease;
}

.delete-btn {
  margin-left: auto;
  background: none;
  border: none;
  color: #e74c3c;
  font-size: 20px;
  cursor: pointer;
}
</style>

Las tareas completadas se mueven suavemente al final de la lista gracias a la clase .todo-move. Los nuevos elementos se deslizan desde arriba y los eliminados se deslizan hacia la derecha, mientras que el resto de la lista se reorganiza con una animación de movimiento fluida.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Vuejs es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Vuejs

Explora más contenido relacionado con Vuejs y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje