Motion for Vue, the motion-v package, is the Vue port of the Motion animation library: springs, gestures, exit animations and layout animations, written as props on a component. Version 2.0.0 went up on npm on 24 February, after five betas between 13 and 23 February. The v2.0.0 release notes and the first beta's notes describe two breaking changes and one new feature.
If your project runs on Vite or Nuxt and you already write whileHover rather than hover, the upgrade is a version bump. The new v-motion directive is the reason to look closer: it animates an element without replacing it with a motion component.
What shipped
The release notes and the beta notes list these changes:
- Breaking: the package is ESM only. The CommonJS build is gone.
- Breaking: the deprecated gesture shorthand props
hover,press,focusandinViewwere removed. - New: a
v-motiondirective animates any HTML or SVG element without a<motion>wrapper. It accepts the animation, gesture, layout and exit props, andMotionPlugincan register your own preset directives. - Internal: imports moved from
framer-motioninternals to the publicmotion-domandmotion-utilspackages, and the feature loading was rebuilt on the same feature-bundle design as Motion for React, for better tree-shaking. Your code needs no change for this unless you useLazyMotion(see the next section).
The betas also fixed several bugs around exits and layout:
- a scheduling deadlock around
onExitComplete - a reorder list that froze when dragged during an exit
- a stray layout animation on mount inside an animating parent
- Vue's CSS transition handling, now switched off inside
AnimatePresence
The documentation folder left the repository; the docs live at motion.dev/docs/vue.
Two breaking changes: ESM only and the old gesture props
ESM only means require('motion-v') no longer works. Vite, Nuxt and Vitest load packages as ES modules already, so most Vue projects never notice. The places that do are older Jest setups and Node scripts written with require.
The exports field of the package's package.json, which tells Node which files the package offers, now lists only import entries, for the main entry, motion-v/nuxt and motion-v/resolver. With no require entry, Node stops with ERR_PACKAGE_PATH_NOT_EXPORTED, even in versions that can load ES modules through require.
The removed props were aliases kept from early versions. In 1.x the types marked hover as deprecated with the note "Use whileHover instead", and the same applied to the other three. In 2.0 they are no longer props at all, so code that still uses them stops animating without an error:
<script setup lang="ts">
import { motion } from 'motion-v'
</script>
<template>
<motion.button type="button" :hover="{ scale: 1.1 }" :press="{ scale: 0.95 }">
Like
</motion.button>
</template>
Rename each one to its while form: hover to whileHover, press to whilePress, focus to whileFocus, inView to whileInView.
<script setup lang="ts">
import { motion } from 'motion-v'
</script>
<template>
<motion.button type="button" :whileHover="{ scale: 1.1 }" :whilePress="{ scale: 0.95 }">
Like
</motion.button>
</template>
One smaller change sits in the types rather than the notes, and it only matters if you use LazyMotion. Its features prop is now required and takes a feature bundle, domAnimation or domMax, or a function that loads one. Code that passes those two exports keeps working.
Animate any element with v-motion
A directive is an attribute that starts with v- and runs code against the element it sits on. Until now, animating an element meant replacing it with a motion component: <h1> became <motion.h1>. The directive leaves your markup alone. Import vMotion in <script setup> and it becomes available as v-motion:
<script setup lang="ts">
import { vMotion } from 'motion-v'
</script>
<template>
<h1
v-motion
:initial="{ opacity: 0, y: 12 }"
:animate="{ opacity: 1, y: 0 }"
:transition="{ duration: 0.4 }"
>
Your cards
</h1>
</template>
The options can sit on the element as separate props, as above, or go into the directive's value as one object: v-motion="{ initial: { opacity: 0 }, animate: { opacity: 1 } }". When both are present, the element's props win.
The directive reads context from parent components too. An element with v-motion and exit inside AnimatePresence animates out when v-if removes it.
To register the directive globally, install MotionPlugin. Its presets option turns a set of options into a directive of its own:
import { createApp } from 'vue'
import { MotionPlugin } from 'motion-v'
import App from './App.vue'
createApp(App)
.use(MotionPlugin, {
presets: {
'fade-in': { initial: { opacity: 0 }, animate: { opacity: 1 } },
},
})
.mount('#app')
Any template can now use v-motion and <p v-fade-in>. In Nuxt, the module does the same from nuxt.config.ts: directives: true registers v-motion (it is off by default), and presets adds your own:
export default defineNuxtConfig({
modules: ['motion-v/nuxt'],
motionV: {
directives: true,
presets: {
'fade-in': { initial: { opacity: 0 }, animate: { opacity: 1 } },
},
},
})
A card list with enter, exit and layout animations
Apart from the removed aliases and LazyMotion, the component API is unchanged, so this list runs on 1.10 as well as 2.0. Cards fade in from above, shrink away when removed, and slide into place when the list is sorted:
<script setup lang="ts">
import { ref } from 'vue'
import { AnimatePresence, motion } from 'motion-v'
interface Card {
id: number
title: string
}
const cards = ref<Card[]>([
{ id: 1, title: 'Slots' },
{ id: 2, title: 'Props' },
{ id: 3, title: 'Events' },
])
let nextId = 4
function add() {
cards.value.unshift({ id: nextId, title: `Card ${nextId}` })
nextId++
}
function remove(id: number) {
cards.value = cards.value.filter(card => card.id !== id)
}
function sortByTitle() {
cards.value = [...cards.value].sort((a, b) => a.title.localeCompare(b.title))
}
</script>
<template>
<button type="button" @click="add">Add card</button>
<button type="button" @click="sortByTitle">Sort A to Z</button>
<ul class="cards">
<AnimatePresence mode="popLayout">
<motion.li
v-for="card in cards"
:key="card.id"
layout
:initial="{ opacity: 0, y: -16 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, scale: 0.9 }"
:whileHover="{ scale: 1.02 }"
>
{{ card.title }}
<button type="button" @click="remove(card.id)">Remove</button>
</motion.li>
</AnimatePresence>
</ul>
</template>
<style scoped>
.cards {
position: relative;
list-style: none;
padding: 0;
}
</style>
What each part does:
initialis where a new card starts andanimateis where it ends up.exitonly works becauseAnimatePresencewraps the list: it keeps a removed card in the page until its exit animation finishes.- The
:keymust be the card's id, never the index, or Motion cannot tell which card left. layoutanimates cards that change position. When a card is added at the top or the list is sorted, each card measures where it was and where it is now, then animates the difference with transforms.mode="popLayout"takes a leaving card out of the flow at once, so the cards below slide up while it fades instead of waiting. The docs ask for a positioned parent in that mode, which is why.cardshasposition: relative.
Cards present on first render animate in too. Pass :initial="false" to AnimatePresence if they should simply appear.
Choose between Motion and Vue's Transition
Vue ships <Transition> and <TransitionGroup>. They add CSS classes such as v-enter-from and v-leave-to at the right moments, and you write the animation in CSS. <TransitionGroup> also moves list items with a v-move class. That covers fades, slides and simple list changes, and it ships with Vue, so there is nothing to install.
Motion runs the animation in JavaScript instead. Springs carry on from the velocity of an interrupted animation, hover gestures ignore touch-screen taps, and layout animations run on transforms with scale correction, between the same or different elements.
AnimatePresence is itself built on Vue's components: its source renders a TransitionGroup, or a Transition in wait mode, with CSS turned off, and Motion runs in the enter and leave hooks.
Use <Transition> for a toggle that fades or slides. Use Motion when the animation reacts to the user, when the layout changes size or order, or when an element should move from one place to another with layoutId.
How to upgrade
Install the new major:
npm install motion-v@2
Then work through three checks:
- Search your components for
hover=,press=,focus=andinView=on motion components and rename each to itswhileform. Leave the colon out of the search, so it also finds a variant name passed as a plain string, such ashover="grow". - If tests run in Jest with CommonJS, move them to Vitest. Jest would need extra configuration to resolve and transform the ES build, because there is no
requireentry to fall back on. - In Nuxt, keep
'motion-v/nuxt'inmodules. The component and composable auto-imports work as before, andvMotionandcreatePresetDirectivejoin the list.
Upgrade now. The breaking changes are small and easy to find with a search, the betas fixed real exit and layout bugs, and v-motion lets you animate a heading or a list item without restructuring the template. The only reason to wait is a CommonJS test setup you cannot change this week.