← Volver al blog

Smooth Animations in Vue 3 with Motion

How to implement high-performance animations in Vue 3 using Motion: from simple fade-ins to complex gestures and scroll-based animations.

Smooth Animations in Vue 3 with Motion

Animations in frontend have a reputation for being frivolous. They aren't: a well-calibrated transition reduces cognitive load, confirms actions, and guides attention. The problem isn't animating — it's animating badly.

Motion (created by Matt Perry, the same author as Framer Motion, and built natively on the Web Animations API) is the tool I reach for most in Vue projects when CSS or native transitions fall short. This article covers the concrete patterns I use in production.


Installation

npm install motion

No extra configuration, no Vite plugins, no Vue wrappers. Motion operates directly on the DOM, which means it works equally well in any Vue context: SFCs, composables, directives.


The base composable

The first mistake I see repeated is putting animate() logic directly inside the component. When three different components need the same fade-up, the code gets duplicated. The solution is to encapsulate it:

// 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>Content with an animated entrance</h2>
  </section>
</template>

The composable returns a ref that the template binds via :ref="el". Animation logic stays completely outside the component.


Scroll-based animations with inView

inView fires a function when an element enters the viewport. The important part: it returns a cleanup function that must be called in onUnmounted, or you'll create orphaned observers every time the component mounts and unmounts:

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 } requires 30% of the element to be visible before firing. Without this, the animation triggers at the exact edge of the viewport, producing a jarring effect on fast scroll.


Stagger: animating lists with template refs

The most common stagger pattern I see is this:

// ❌ Anti-pattern
const elements = document.querySelectorAll('.list-item')
animate(elements, ...)

The problem: it depends on a CSS class name that can change, it can capture elements from other components on the same page, and it breaks Vue's encapsulation model. The correct approach is 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" on the element prevents the initial flash before Motion takes over. A small detail that separates a polished animation from one that flickers.


Magnetic buttons

The magnetic effect — the element shifts smoothly toward the cursor when nearby — is one of the most effective ways to communicate attention to detail:

// 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 over me</button>
</template>

strength controls the displacement intensity. 0.3 is subtle; 0.6 already feels exaggerated in most contexts.


Timeline: sequences with precise overlaps

For intro screens or sequences where multiple animations need to chain with controlled overlaps, timeline avoids computing delays by hand:

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' means "start this animation 0.4s before the previous one ends." With three elements the full sequence takes ~1.2s instead of the 1.8s a strictly sequential approach would need.


Accessibility: prefers-reduced-motion

Some users disable animations for medical reasons — epilepsy, vertigo, migraines. Ignoring prefers-reduced-motion isn't an aesthetic choice; it's an accessibility failure.

The common shortcut is this:

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

Too aggressive. It also kills state transitions (focus, hover, loading) that provide critical visual feedback. The user loses orientation in the UI.

The correct approach is to reduce, not eliminate. The right composable for Nuxt (SSR-safe, with listener cleanup):

// 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 }
)

Movement disappears for users with reduce enabled, but the fade stays — enough so the element doesn't pop in abruptly. When you operate at the animate() level directly, this responsibility is yours.


Motion vs GSAP

GSAP is more powerful and has more years in production. But on bundle-sensitive projects:

MotionGSAP
Bundle size~18 kb~60 kb+
PriceFreeClub ($) for ScrollTrigger and other features
APIPromise-based, modernMore verbose, callback-based
ScrollinView built-inSeparate plugin

Motion covers 95% of cases with less weight. The remaining 5% — SVG path morphing, complex ScrollTrigger with pins and scrub — I handle with GSAP when needed. They're complementary tools, not competing ones.


Conclusion

The pattern that holds up in production:

  1. Encapsulate all animation logic in composables — never in the template
  2. Always clean up observers and listeners in onUnmounted
  3. Use template refs, not querySelectorAll with class names
  4. Respect prefers-reduced-motion with an SSR-safe composable
  5. Default to Motion for the 95%, reach for GSAP when there's a specific reason

Questions or want to see a specific example? Reach out at hola@miguel-jimenez.dev.