← Volver al blog

Animaciones fluidas en Vue 3 con Motion

Cómo implementar animaciones de alto rendimiento en Vue 3 usando Motion: desde fade-ins sencillos hasta gestos complejos y animaciones basadas en scroll.

Animaciones fluidas en Vue 3 con Motion

Las animaciones en frontend tienen mala fama de ser frívolas. No lo son: una transición bien calibrada reduce la carga cognitiva, confirma acciones y guía la atención. El problema no es animar — es animar mal.

Motion (creado por Matt Perry, el mismo autor de Framer Motion, y construido sobre Web Animations API) es la herramienta que más uso en proyectos Vue cuando CSS o las transiciones nativas se quedan cortas. En este artículo recorro los patrones concretos que aplico en producción.


Instalación

npm install motion

Sin configuración adicional, sin plugins de Vite, sin wrappers de Vue. Motion opera directamente sobre el DOM, lo que significa que funciona igual en cualquier contexto de Vue: SFC, composables, directivas.


El composable base

El primer error que veo repetirse es poner la lógica de animate() directamente en el componente. Cuando tres componentes distintos necesitan el mismo fade-up, el código se duplica. La solución es encapsularlo:

// composables/useFadeUp.ts
import { animate } from 'motion'
import type { AnimationPlaybackOptions } from 'motion'

export function useFadeUp(options?: AnimationPlaybackOptions) {
  const el = ref<HTMLElement | null>(null)

  onMounted(() => {
    if (!el.value) return
    animate(
      el.value,
      { opacity: [0, 1], y: [24, 0] },
      { duration: 0.6, easing: [0.25, 0.1, 0.25, 1], ...options }
    )
  })

  return { el }
}
<script setup lang="ts">
const { el } = useFadeUp({ delay: 0.2 })
</script>

<template>
  <section ref="el">
    <h2>Contenido con entrada animada</h2>
  </section>
</template>

El composable retorna un ref que el template enlaza vía :ref="el". La lógica de animación queda completamente fuera del componente.


Animaciones basadas en scroll con inView

inView dispara una función cuando un elemento entra en el viewport. Lo importante: devuelve una función de limpieza que hay que llamar en onUnmounted, o crearás observers huérfanos cada vez que el componente se monte y desmonte:

import { inView, animate } from 'motion'

const el = ref<HTMLElement | null>(null)

onMounted(() => {
  if (!el.value) return

  const stop = inView(
    el.value,
    ({ target }) => {
      animate(target, { opacity: [0, 1], y: [32, 0] }, { duration: 0.7, easing: 'ease-out' })
    },
    { amount: 0.3 }
  )

  onUnmounted(stop)
})

{ amount: 0.3 } requiere que el 30% del elemento sea visible antes de disparar. Sin esto, la animación se activa en el borde exacto del viewport, lo que produce un efecto abrupto en scroll rápido.


Stagger: animar listas con template refs

El patrón que veo más seguido para stagger es este:

// ❌ Antipatrón
const elements = document.querySelectorAll('.list-item')
animate(elements, ...)

El problema: depende de una clase CSS que puede cambiar, puede capturar elementos de otros componentes en la misma página, y rompe el encapsulamiento de Vue. La forma correcta es usar template refs:

// composables/useStaggerIn.ts
import { animate, stagger } from 'motion'

export function useStaggerIn(delayBetween = 0.08) {
  const items = ref<HTMLElement[]>([])

  function register(el: Element | ComponentPublicInstance | null) {
    if (el instanceof HTMLElement) items.value.push(el)
  }

  onMounted(() => {
    if (!items.value.length) return
    animate(
      items.value,
      { opacity: [0, 1], x: [-16, 0] },
      { duration: 0.4, delay: stagger(delayBetween), easing: 'ease-out' }
    )
  })

  return { register }
}
<script setup lang="ts">
const { register } = useStaggerIn(0.06)
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      :ref="register"
      style="opacity: 0"
    >
      {{ item.label }}
    </li>
  </ul>
</template>

style="opacity: 0" en el elemento previene el flash inicial antes de que Motion tome el control. Es un detalle pequeño que la diferencia entre una animación pulida y una que parpadea.


Animaciones magnéticas en botones

El efecto magnético — el elemento se desplaza suavemente hacia el cursor cuando está cerca — es uno de los más efectivos para transmitir cuidado en el detalle:

// composables/useMagnetic.ts
import { animate } from 'motion'

export function useMagnetic(strength = 0.3) {
  const el = ref<HTMLElement | null>(null)

  function onMouseMove(e: MouseEvent) {
    if (!el.value) return
    const rect = el.value.getBoundingClientRect()
    const cx = rect.left + rect.width / 2
    const cy = rect.top + rect.height / 2
    const dx = (e.clientX - cx) * strength
    const dy = (e.clientY - cy) * strength
    animate(el.value, { x: dx, y: dy }, { duration: 0.3, easing: 'ease-out' })
  }

  function onMouseLeave() {
    if (!el.value) return
    animate(el.value, { x: 0, y: 0 }, { duration: 0.5, easing: [0.25, 0.1, 0.25, 1] })
  }

  onMounted(() => {
    el.value?.addEventListener('mousemove', onMouseMove)
    el.value?.addEventListener('mouseleave', onMouseLeave)
  })

  onUnmounted(() => {
    el.value?.removeEventListener('mousemove', onMouseMove)
    el.value?.removeEventListener('mouseleave', onMouseLeave)
  })

  return { el }
}
<script setup lang="ts">
const { el } = useMagnetic(0.4)
</script>

<template>
  <button ref="el" class="btn">Hover sobre mí</button>
</template>

strength controla la intensidad del desplazamiento. 0.3 es discreto; 0.6 ya resulta exagerado en la mayoría de contextos.


Timeline: secuencias con overlaps precisos

Para intro screens o secuencias donde varias animaciones deben encadenarse con overlaps controlados, timeline evita calcular delays manualmente:

import { timeline } from 'motion'

async function playIntro() {
  await timeline([
    ['.hero-title',    { opacity: [0, 1], y: [40, 0] }, { duration: 0.8 }],
    ['.hero-subtitle', { opacity: [0, 1], y: [20, 0] }, { duration: 0.6, at: '-0.4' }],
    ['.hero-cta',      { opacity: [0, 1], scale: [0.9, 1] }, { duration: 0.4, at: '-0.2' }],
  ]).finished
}

onMounted(playIntro)

at: '-0.4' significa "empieza esta animación 0.4s antes de que termine la anterior". Con tres elementos la secuencia completa dura ~1.2s en lugar de los 1.8s que necesitaría si fuera estrictamente secuencial.


Accesibilidad: prefers-reduced-motion

Algunos usuarios desactivan las animaciones por razones médicas — epilepsia, vértigo, migrañas. No respetar prefers-reduced-motion no es un problema estético, es accesibilidad.

La trampa habitual es esta:

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

Demasiado agresivo. Elimina también las transiciones de estado (focus, hover, loading) que dan feedback visual crítico. El usuario pierde orientación en la UI.

La aproximación correcta es reducir, no eliminar. El composable correcto para Nuxt (SSR-safe, con limpieza del listener):

// composables/useMotionSafe.ts
export function useMotionSafe() {
  const reduced = ref(false)

  onMounted(() => {
    const query = window.matchMedia('(prefers-reduced-motion: reduce)')
    reduced.value = query.matches
    const handler = (e: MediaQueryListEvent) => { reduced.value = e.matches }
    query.addEventListener('change', handler)
    onUnmounted(() => query.removeEventListener('change', handler))
  })

  return { reduced }
}
const { reduced } = useMotionSafe()

animate(
  el.value,
  { opacity: [0, 1], y: reduced.value ? [0, 0] : [24, 0] },
  { duration: reduced.value ? 0.15 : 0.6 }
)

El desplazamiento desaparece para usuarios con reduce, pero el fade permanece — suficiente para que la aparición del elemento no sea brusca. Cuando operas a nivel de animate() directamente, esta responsabilidad es tuya.


¿Por qué Motion y no GSAP?

GSAP es más potente y lleva más años en producción. Pero en proyectos donde el bundle importa:

MotionGSAP
Tamaño~18 kb~60 kb+
PrecioGratisClub ($) para ScrollTrigger y otras features
APIPromise-based, modernaMás verbosa, basada en callbacks
ScrollinView nativoPlugin aparte

Motion cubre el 95% de los casos con menos peso. El 5% restante — morphing de paths SVG, ScrollTrigger avanzado con pins y scrub — lo cubro con GSAP cuando es necesario. Son herramientas complementarias, no excluyentes.


Conclusión

El patrón que funciona en producción:

  1. Encapsular toda la lógica de animación en composables — nunca en el template
  2. Limpiar siempre los observers y listeners en onUnmounted
  3. Usar template refs, no querySelectorAll con clases
  4. Respetar prefers-reduced-motion con un composable SSR-safe
  5. Elegir Motion para el 95%, GSAP cuando hay una razón específica

Si tienes dudas o quieres ver algún ejemplo concreto, escríbeme a hola@miguel-jimenez.dev.