Skip to content
TutorialAnimation

Page transitions with the View Transitions API and motion-v

The browser can animate between two versions of your page by itself. You give it the moment the DOM changes, Vue Router or Nuxt does the rest, and motion-v handles the smaller movements inside a page.

vuejs.school team30 Jul 20267 min readVue 3.5 · Nuxt 4.5 · motion-v 2.3

A route change in a single-page app is instant: one page disappears and the next appears. That is fast, but it can feel abrupt, and the reader loses track of where the thing they clicked went. The browser's View Transitions API animates between the two pages for you, and can fly the photo someone clicked into its place on the next page. It takes a small plugin in a Vue Router 5 app and one config flag in Nuxt 4.5, and motion-v covers the smaller movements inside a page.

The plain Vue examples use a Vite project with code in src/. The Nuxt examples use the Nuxt 4 layout with code in app/.

What the View Transitions API does

The API has one entry point: document.startViewTransition(callback). The browser takes a snapshot of the page, calls your callback so you can change the DOM, waits for the promise it returns, takes a second snapshot, and animates between the two. By default that animation is a cross-fade of the whole page.

The animation is built from pseudo-elements, which are boxes the browser creates for you and you can style with CSS:

  • ::view-transition is an overlay on top of the page that holds everything else.
  • ::view-transition-group(name) animates the size and position of one captured element.
  • ::view-transition-image-pair(name) holds the two snapshots of that element.
  • ::view-transition-old(name) is a still image of the old state; ::view-transition-new(name) is a live view of the new one.

The whole page is captured under the name root. Any element with a view-transition-name in CSS is captured separately, and if an element with the same name exists before and after, the browser moves and resizes it from one position to the other. You style all of this with ordinary CSS animations in a global stylesheet, here src/assets/transitions.css, imported once in src/main.ts:

src/assets/transitions.css
html::view-transition-old(root),
html::view-transition-new(root) {
  animation-duration: 200ms;
}

html::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: ease-out;
}

The * matches every named group, so all morphing elements share one timing. The html prefix is optional, since document.startViewTransition builds these pseudo-elements on the root element. A component's <style scoped> block will not work: Vue adds its scope attribute to each selector, and the root element never has it.

Which browsers support view transitions

The browser compatibility data MDN published on 24 July lists document.startViewTransition in Chrome and Edge 111, Safari 18 and Firefox 144. The newer pieces arrived later: view transition types and the object form of startViewTransition need Chrome 125, Safari 18.2 or Firefox 147. Transitions between separate HTML documents, declared with the @view-transition rule, work in Chrome and Safari but not in Firefox. This post only uses transitions inside one document, which is what a Vue app needs.

In an older browser nothing breaks. You check that the function exists, and if it does not, navigation simply happens without animation. Treat the effect as a bonus, never as the only cue that the page changed.

Add view transitions to Vue Router 5

The router needs to start the transition before it swaps the page and end it once Vue has updated the DOM. A beforeResolve guard is the right spot: it runs after every other guard and after lazy route components have loaded, just before the navigation is confirmed. This plugin follows the same shape as the one Nuxt ships:

src/router/view-transitions.ts
import { nextTick } from 'vue'
import type { Router } from 'vue-router'

export function addViewTransitions(router: Router) {
  if (!('startViewTransition' in document)) return

  let finish: (() => void) | undefined

  router.beforeResolve((to, from) => {
    if (!from.matched.length || to.path === from.path) return
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return

    return new Promise<void>((resolve) => {
      document.startViewTransition(() => {
        resolve()
        return new Promise<void>((done) => { finish = done })
      })
    })
  })

  router.afterEach(async () => {
    await nextTick()
    finish?.()
    finish = undefined
  })
}

Two promises do the work:

  1. The guard returns a promise, so the navigation waits.
  2. The browser takes its snapshot of the old page and calls the callback, which resolves that promise and lets the navigation continue.
  3. The callback itself returns a second promise, which keeps the browser waiting while the route changes.
  4. afterEach waits one nextTick for Vue to render the new page, then resolves the second promise, and the browser animates.

afterEach also runs when a navigation fails, so a cancelled navigation never leaves the screen frozen. The checks at the top skip the transition in browsers without the API, on the first page load, on changes that only touch the query string or hash, and for people who ask for reduced motion.

Call it once where you create the router:

src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { addViewTransitions } from './view-transitions'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('../views/PhotoList.vue') },
    { path: '/photos/:id', component: () => import('../views/PhotoDetail.vue'), props: true },
  ],
})

addViewTransitions(router)

export default router

Morph a photo from the list to the detail page

Now the part that makes view transitions worth it. Give the thumbnail in the list and the large photo on the detail page the same view-transition-name, and the browser flies one into the other. The photos come from a small data file:

src/data/photos.ts
export const photos = [
  { id: 1, title: 'Harbour at dawn', src: '/photos/harbour.jpg' },
  { id: 2, title: 'Old town roofs', src: '/photos/roofs.jpg' },
  { id: 3, title: 'Market stalls', src: '/photos/market.jpg' },
]

Both pages set the name in a :style binding (viewTransitionName is the camelCase form of view-transition-name), built from the photo's id, so photo 1 is photo-1 on both:

src/views/PhotoList.vue
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { photos } from '../data/photos'
</script>

<template>
  <ul class="grid">
    <li v-for="photo in photos" :key="photo.id">
      <RouterLink :to="`/photos/${photo.id}`">
        <img :src="photo.src" :alt="photo.title" :style="{ viewTransitionName: `photo-${photo.id}` }">
      </RouterLink>
    </li>
  </ul>
</template>
src/views/PhotoDetail.vue
<script setup lang="ts">
import { computed } from 'vue'
import { photos } from '../data/photos'

const props = defineProps<{ id: string }>()
const photo = computed(() => photos.find(p => p.id === Number(props.id)))
</script>

<template>
  <article v-if="photo">
    <img :src="photo.src" :alt="photo.title" :style="{ viewTransitionName: `photo-${photo.id}` }">
    <h1>{{ photo.title }}</h1>
  </article>
</template>

Two rules make this work:

  • A name must be unique on the page at the moment of each snapshot, which is why every list item gets its own name built from its id.
  • The new state is captured as soon as the DOM updates, so if the detail page shows a different, larger file that has not downloaded yet, the photo morphs into an empty box. Use the same URL on both pages, as here, or make sure the larger file is already cached.

Turn on view transitions in Nuxt 4.5

Nuxt has this plugin built in. Switch it on in the config:

nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    viewTransition: true,
  },
})

Every page change now cross-fades, and the viewTransitionName bindings from the previous section work unchanged in pages under app/pages/. If you move PhotoDetail.vue to app/pages/photos/[id].vue, add definePageMeta({ props: true }) so it still receives id as a prop. Put transitions.css in app/assets/ and load it with css: ['~/assets/transitions.css'] in the config.

To opt a single page out, set definePageMeta({ viewTransition: false }) in it. For a different animation going forward and back, use view transition types: labels you set in the page's viewTransition object in definePageMeta (types, or toTypes and fromTypes for arriving at and leaving that page). CSS targets them with html:active-view-transition-type(slide-left).

So a page that awaits data in its setup holds the old screen until the data arrives: if it calls await useFetch() and the API takes a second, nothing moves for that second. For slow pages, use the lazy option so the page renders straight away with a loading state, or leave view transitions off for that page.

Respect reduced motion

Some people turn on "reduce motion" in their operating system because large movement makes them unwell. The browser then reports prefers-reduced-motion: reduce, and both setups above skip the transition: the plain Vue plugin through its matchMedia check, and Nuxt because viewTransition: true makes the same check.

To keep a gentle cross-fade and drop only the movement, let the transition run and handle reduced motion in CSS. Remove the matchMedia line from the plugin (in Nuxt, set viewTransition: 'always', which animates regardless and leaves the decision to you), then add this rule to transitions.css:

src/assets/transitions.css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) {
    animation: none;
  }
}

The named elements no longer move or resize, and the old and new snapshots still fade into each other.

Animate elements inside a page with motion-v

View transitions are for the moment the page changes. Inside a page, smaller things move too: a filtered list loses items, a panel opens, a button reacts to a press. motion-v 2.3, Motion's Vue library (npm install motion-v), handles these with components. An item animates in from initial to animate, and AnimatePresence lets it play an exit animation before Vue removes it. This component animates a list that its parent filters:

src/components/FilteredList.vue
<script setup lang="ts">
import { AnimatePresence, motion } from 'motion-v'

defineProps<{ items: { id: number, name: string }[] }>()
</script>

<template>
  <ul>
    <AnimatePresence :initial="false">
      <motion.li
        v-for="item in items"
        :key="item.id"
        layout
        :initial="{ opacity: 0, y: 8 }"
        :animate="{ opacity: 1, y: 0 }"
        :exit="{ opacity: 0 }"
        :transition="{ duration: 0.2 }"
      >
        {{ item.name }}
      </motion.li>
    </AnimatePresence>
  </ul>
</template>

layout slides the remaining items into their new places when one leaves, and :initial="false" on AnimatePresence stops the whole list animating on first render. In Nuxt, add 'motion-v/nuxt' to modules; it auto-imports the Motion component (write <Motion as="li">) along with AnimatePresence and MotionConfig.

motion-v does not respect reduced motion unless you tell it to: its default is "never". Wrap your app once, for example with <MotionConfig reduced-motion="user"> around <RouterView /> in App.vue. Motion's docs say that with reduced motion on, transform and layout animations are disabled while opacity and colour animations still run, which is usually what you want.

Choose between view transitions and motion-v

Use the View Transitions API when the route changes, and especially when one element should visibly carry over from one page to the next. It costs no JavaScript library, and the browser does the work. Use motion-v for anything that happens while the route stays the same: lists, toggles, gestures, springs. Do not animate the same element with both at once, because a view transition snapshots the old page, and a motion-v exit animation on that page will never be seen.

Check it stuck3 questions from the Motion for Vue and auto-animate quiz.
Practice now
Was this helpful?

Keep reading