En esta página

Transiciones y animaciones en Vue

12 min lectura TextoCap. 5 — Producción

El sistema de transiciones de Vue

Vue proporciona dos componentes integrados para animar elementos:

  • <Transition> — Anima la aparición y desaparición de un solo elemento o componente
  • <TransitionGroup> — Anima listas de elementos (añadir, eliminar, reordenar)

La diferencia clave con las animaciones CSS puras es que Vue gestiona automáticamente cuándo aplicar las clases CSS en función del ciclo de vida del elemento.

Cómo funciona Transition

Cuando un elemento dentro de <Transition> entra o sale del DOM (por v-if, v-show, o un cambio de componente dinámico), Vue añade clases CSS en momentos específicos:

Ciclo de entrada:

  1. nombre-enter-from — Estado inicial (antes de aparecer)
  2. nombre-enter-active — Toda la duración de la entrada (aquí va la transition/animation CSS)
  3. nombre-enter-to — Estado final de la entrada

Ciclo de salida:

  1. nombre-leave-from — Estado inicial de la salida
  2. nombre-leave-active — Toda la duración de la salida
  3. nombre-leave-to — Estado final (cuando desaparece)
/* Transición "fade" */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

/* Inicio de entrada = mismo que final de salida */
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* .fade-enter-to y .fade-leave-from son el estado "visible" — generalmente no necesitan CSS */

Transición de página con Vue Router

Las transiciones entre rutas son uno de los usos más comunes:

<!-- App.vue -->
<template>
  <RouterView v-slot="{ Component, route }">
    <Transition :name="route.meta.transicion ?? 'fade'" mode="out-in">
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

Hooks de JavaScript — animaciones con librerías

Además de CSS, <Transition> expone hooks de JavaScript para usar librerías como GSAP:

<script setup lang="ts">
// Necesitarías instalar gsap: npm install gsap
// import gsap from 'gsap'

function alEntrar(el: Element, done: () => void): void {
  // gsap.from(el, { opacity: 0, y: 20, duration: 0.3, onComplete: done })
  const elemento = el as HTMLElement
  elemento.style.opacity = '0'
  elemento.style.transform = 'translateY(20px)'
  setTimeout(() => {
    elemento.style.transition = 'all 0.3s ease'
    elemento.style.opacity = '1'
    elemento.style.transform = 'translateY(0)'
    done()
  }, 10)
}

function alSalir(el: Element, done: () => void): void {
  const elemento = el as HTMLElement
  elemento.style.transition = 'all 0.3s ease'
  elemento.style.opacity = '0'
  elemento.style.transform = 'translateY(-20px)'
  setTimeout(done, 300)
}
</script>

<template>
  <Transition
    :css="false"
    @enter="alEntrar"
    @leave="alSalir"
  >
    <div v-if="visible">Contenido</div>
  </Transition>
</template>

La prop :css="false" le dice a Vue que no añada las clases CSS automáticas, dejando todo el control a los hooks de JavaScript.

Integración con Animate.css

Animate.css es una popular librería de animaciones CSS:

npm install animate.css
// main.ts
import 'animate.css'
<template>
  <Transition
    enter-active-class="animate__animated animate__fadeInDown"
    leave-active-class="animate__animated animate__fadeOutUp"
  >
    <div v-if="visible">Animado con Animate.css</div>
  </Transition>
</template>

TransitionGroup — listas animadas

<TransitionGroup> es similar a <Transition> pero para listas. Diferencias clave:

  1. Renderiza un elemento DOM real (por defecto un <span>, configurable con tag)
  2. Cada elemento debe tener un :key único
  3. Soporta la clase -move para animar el reordenamiento
<template>
  <TransitionGroup name="lista" tag="div" class="grid">
    <TarjetaProducto
      v-for="producto in productosFiltrados"
      :key="producto.id"
      :producto="producto"
    />
  </TransitionGroup>
</template>

<style>
/* Las tarjetas aparecen desde abajo */
.lista-enter-from {
  opacity: 0;
  transform: translateY(30px);
}

/* Las tarjetas se desvanecen hacia arriba al eliminarse */
.lista-leave-to {
  opacity: 0;
  transform: translateY(-30px);
}

/* Duración de entrada y salida */
.lista-enter-active,
.lista-leave-active {
  transition: all 0.4s ease;
}

/* ¡Clave! Las tarjetas restantes se mueven suavemente al reordenarse */
.lista-move {
  transition: transform 0.4s ease;
}

/* El elemento que sale debe salir del flujo para no empujar a los demás */
.lista-leave-active {
  position: absolute;
}
</style>

Transición con appear — animación al montar

La prop appear hace que la transición también se ejecute cuando el componente se monta por primera vez:

<template>
  <Transition name="fade" appear>
    <div>Este elemento se anima al aparecer por primera vez</div>
  </Transition>
</template>

Animaciones con CSS custom properties

Puedes usar CSS Custom Properties para hacer las animaciones configurables:

<template>
  <Transition name="custom">
    <div v-if="visible" :style="{ '--duracion': `${duracion}ms` }">
      Animación configurable
    </div>
  </Transition>
</template>

<style scoped>
.custom-enter-active,
.custom-leave-active {
  transition: all var(--duracion, 300ms) ease;
}
.custom-enter-from,
.custom-leave-to {
  opacity: 0;
  transform: scale(0.9);
}
</style>

Animaciones de números — composable useAnimatedNumber

import { ref, watch } from 'vue'

export function useNumeroAnimado(objetivo: () => number, duracion = 500) {
  const actual = ref(objetivo())

  watch(objetivo, (nuevo, anterior) => {
    const inicio = anterior
    const fin = nuevo
    const tiempoInicio = performance.now()

    function animar(ahora: number): void {
      const progreso = Math.min((ahora - tiempoInicio) / duracion, 1)
      // Easing ease-out
      const facilidad = 1 - Math.pow(1 - progreso, 3)
      actual.value = Math.round(inicio + (fin - inicio) * facilidad)
      if (progreso < 1) requestAnimationFrame(animar)
    }

    requestAnimationFrame(animar)
  })

  return actual
}

Accesibilidad y animaciones

Siempre respeta la preferencia del usuario por movimiento reducido:

/* En tu archivo global de estilos */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Práctica

  1. Carrusel con transiciones: Crea un carrusel de imágenes donde cada slide tenga una transición slide-in/slide-out. Usa mode="out-in" y el estado anterior de la dirección para animar hacia adelante o hacia atrás.
  2. Lista de tareas animada: Añade animaciones con TransitionGroup a una lista de tareas. Las tareas que se añaden deben aparecer desde la derecha, las que se eliminan deben desvanecerse hacia la izquierda, y el resto debe moverse suavemente con la clase -move.
  3. Toast notifications animados: Crea un sistema de notificaciones toast donde cada toast aparece desde arriba con TransitionGroup. Las notificaciones se auto-eliminan después de 3 segundos.

En la siguiente lección aprenderemos a escribir tests para nuestros componentes Vue con Vitest y Vue Test Utils.

mode='out-in' — esperar a que termine la salida antes de entrar
Por defecto, las animaciones de entrada y salida ocurren simultáneamente. Con mode='out-in', el elemento saliente termina su animación antes de que el entrante comience. Esto evita que dos elementos ocupen el mismo espacio al mismo tiempo y es ideal para transiciones de páginas.
TransitionGroup y la clase -move
TransitionGroup añade automáticamente la clase nombre-move cuando los elementos existentes cambian de posición (al añadir, eliminar o reordenar). Para que el movimiento se anime suavemente, define .nombre-move { transition: transform Xs ease }. Vue usa FLIP animation internamente para el cálculo de posiciones.
Accesibilidad — respeta prefers-reduced-motion
Algunos usuarios tienen configurado prefers-reduced-motion en su sistema operativo por problemas de salud. Respeta esta preferencia: usa @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } en tus estilos globales.
vue
<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(true)
const modo = ref<'fade' | 'slide' | 'scale'>('fade')
</script>

<template>
  <div class="controles">
    <button type="button" @click="visible = !visible">
      {{ visible ? 'Ocultar' : 'Mostrar' }}
    </button>
    <select v-model="modo">
      <option value="fade">Fade</option>
      <option value="slide">Slide</option>
      <option value="scale">Scale</option>
    </select>
  </div>

  <!-- Transition envuelve UN solo elemento o componente -->
  <Transition :name="modo" mode="out-in">
    <div v-if="visible" :key="modo" class="caja">
      Contenido animado (modo: {{ modo }})
    </div>
  </Transition>
</template>

<style scoped>
/* Clases generadas por Vue para la transición "fade" */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* Slide */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
  transform: translateX(-20px);
  opacity: 0;
}
.slide-leave-to {
  transform: translateX(20px);
  opacity: 0;
}

/* Scale */
.scale-enter-active,
.scale-leave-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
  transform: scale(0.8);
  opacity: 0;
}
</style>
vue
<script setup lang="ts">
import { ref } from 'vue'

interface Item {
  id: number
  texto: string
}

let siguienteId = 4
const items = ref<Item[]>([
  { id: 1, texto: 'Vue 3' },
  { id: 2, texto: 'Pinia' },
  { id: 3, texto: 'Vue Router' },
])

function agregar(): void {
  items.value.push({ id: siguienteId++, texto: `Item ${siguienteId}` })
}

function eliminar(id: number): void {
  items.value = items.value.filter(i => i.id !== id)
}

function mezclar(): void {
  items.value = [...items.value].sort(() => Math.random() - 0.5)
}
</script>

<template>
  <div class="controles">
    <button type="button" @click="agregar">+ Agregar</button>
    <button type="button" @click="mezclar">⇄ Mezclar</button>
  </div>

  <!-- TransitionGroup anima listas — requiere :key en cada elemento -->
  <TransitionGroup name="lista" tag="ul" class="lista">
    <li
      v-for="item in items"
      :key="item.id"
      class="item"
    >
      {{ item.texto }}
      <button type="button" @click="eliminar(item.id)">×</button>
    </li>
  </TransitionGroup>
</template>

<style scoped>
.lista {
  list-style: none;
  padding: 0;
  position: relative; /* Necesario para las animaciones de reordenamiento */
}

.lista-enter-active,
.lista-leave-active {
  transition: all 0.4s ease;
}

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

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

/* Animar el movimiento de elementos al reordenar */
.lista-move {
  transition: transform 0.4s ease;
}

/* Sacar del flujo durante la salida para que los demás se muevan suavemente */
.lista-leave-active {
  position: absolute;
  width: 100%;
}
</style>