Page and view transitions
Fade or slide between pages with Transition around the router view, let Nuxt do it with pageTransition and layoutTransition, and let the browser morph a product image from the grid into its page with the View Transitions API.
What you'll learn
When you finish, you can do these.- 1Fade between pages with Transition around RouterView
- 2Turn on page transitions in Nuxt with pageTransition
- 3Morph a grid image into its page with View Transitions
- 4Fix a page change that skips its animation or goes blank
Picture a small online shop whose home page shows a grid: a mug, a teapot and a tray, each a link to its own page. Tap the mug and its page replaces the grid at once. Nothing tells the eye where it came from.
Three tools animate that change. You can wrap the page in Vue's <Transition> or let Nuxt, a framework built on Vue, do it for you. The third, the browser's View Transitions API, carries the mug's image from the grid into its page. You need one idea from Enter and leave with Transition, where a mug's page swaps one tab panel for another: <Transition> adds CSS classes to both panels as they swap, and your CSS turns them into a fade. The Code tab holds the shop as a Nuxt app under app/, plus two plain Vue files under src/.
Transition around the router view
The first tool is a <Transition> you place yourself. In a plain Vue app, Vue Router, the official library that matches each address to a page component, shows that page in <RouterView>. Its v-slot attribute hands you the page as Component and the address as route. <component :is="Component">, a tag that renders whichever component it is given, puts the page inside a <Transition>. In the Code tab this is src/App.vue:
<RouterView v-slot="{ Component, route }">
<Transition name="page" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
name="page" makes the classes <Transition> adds start with page-. The same file's style block turns them into a fade:
.page-enter-active, .page-leave-active { transition: opacity 0.2s ease; }
.page-enter-from, .page-leave-to { opacity: 0; }
Click a link and the old page fades out, then the new one fades in: mode="out-in" waits for the leave to end. Without it, both pages sit in the document for a moment, one under the other.
The :key matters too. /products/1 and /products/2 match the same product page, so without a key Vue reuses it, changes its text, and nothing animates. Keyed by route.path, every address is a new element and the swap runs.
Nuxt does the wrapping
Nuxt sets up that wrapper for you: <NuxtPage>, the tag that shows the current page in the Code tab's app/app.vue, already contains a <Transition>. You turn it on in nuxt.config.ts, the settings file:
export default defineNuxtConfig({
app: {
pageTransition: { name: 'page', mode: 'out-in' },
layoutTransition: { name: 'layout', mode: 'out-in' },
},
})
pageTransition takes the props of <Transition>, and the same two page- rules go in app.vue, where a <style> block without scoped reaches every page.
A page can pick its own transition with definePageMeta, Nuxt's function for page settings. The product page, app/pages/products/[id].vue, sets pageTransition: { name: 'slide', mode: 'out-in' } and slides in; pageTransition: false would turn it off.
When the layout, the shared frame around pages, changes too, the page transition does not run: layoutTransition runs instead, with layout- classes. Pages and layouts need a single root element, one element that wraps the rest.
The browser morphs the image
<Transition> cannot carry the mug's image into its page: the two pages share no element. The View Transitions API can: it works on the page as a picture.
- Snapshot the old page
- Update the DOM
- Snapshot the new page
- Animate the pseudo-elements
document.startViewTransition(update) takes a function that changes the DOM, the browser's model of the page. The browser snapshots the page, runs update, snapshots again, then cross-fades the two pictures on pseudo-elements, stand-in elements it creates.
Between pages, Nuxt makes that call for you. To see it written out, take one product's photo gallery, a big image above a row of thumbnails: src/components/ProductGallery.vue in the Code tab. A click on a thumbnail calls select, which stores its address in selected, a ref that Vue watches:
function select(src: string) {
const update = () => { selected.value = src; return nextTick() }
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) return update()
document.startViewTransition(update)
}
The big image cross-fades to the new one. Vue updates the DOM a moment after a ref changes, and update returns nextTick(), which waits for that moment, so the second snapshot shows the change.
The if gives older browsers, and devices set to reduce motion, a plain update.
Back in the shop, the morph comes from view-transition-name, a CSS property that names an element. When an element on the old page and one on the new share a name, the browser animates position and size from one to the other. The grid, app/pages/index.vue, names each image product-1, product-2 and so on. The product page names its big image from its id, product-1 on the mug's page, so the thumbnail grows into place. A name belongs to one element on a page at a time.
In Nuxt, experimental: { viewTransition: true } wraps every change of page in startViewTransition and skips it under reduced motion.
With a page transition on too, the Vue fade plays inside the browser's cross-fade, so the Code tab's middleware, code that runs before each change of page, turns that fade off where the API exists.
Separate HTML pages get the same across a full page load with @view-transition { navigation: auto } in the CSS of both.
What goes wrong
Two mistakes break a change of page. In the first, a CSS rule in the grid, app/pages/index.vue, names every card's image product-image, for the product page to share:
.card img { view-transition-name: product-image; }
Click a card and the page changes with no animation, not even the cross-fade, and the console shows Unexpected duplicate view-transition-name: product-image. Give each image its own name from the product's id, as the Code tab's grid does:
<img :src="`/images/${p.id}.jpg`" :alt="p.name" :style="{ viewTransitionName: `product-${p.id}` }">
The second is a page with two root elements while pageTransition is on. Picture an about page, app/pages/about.vue, not in the Code tab, with a heading and a paragraph:
<template>
<h1>About the shop</h1>
<p>Opening hours and the address.</p>
</template>
The page opens with the warning [NUXT_E4004], the file's path, and does not have a single root node and will cause errors when navigating between routes. Leaving it is the error: go to the shop, and the page area stays empty, because the transition waits for a leave that two root elements cannot run. Wrap the page in one element:
<template>
<main>
<h1>About the shop</h1>
<p>Opening hours and the address.</p>
</main>
</template>
The about page then fades out and the grid fades in.
What to remember
- A
<Transition mode="out-in">around the page from<RouterView>animates every change of page;:key="route.path"makes/products/1to/products/2a swap too. - In Nuxt,
app.pageTransitionsets up that wrapper,definePageMetaoverrides it per page, and every page needs one root element. document.startViewTransition(update)snapshots, updates and animates. In Vue,updatereturnsnextTick().- Elements on two pages with the same
view-transition-namemorph into each other; a duplicate name on one page skips the transition. - Under reduced motion,
viewTransition: trueskips the view transition; Vue transitions need aprefers-reduced-motionmedia query.
In your project
Do these in a real project. Each one says how to confirm it worked.- In a Nuxt project with
experimental: { viewTransition: true }, copy the Code tab'sapp/pages/files and put1.jpgto3.jpginpublic/images/. Add::view-transition-group(*) { animation-duration: 2s; }toapp.vueand click a product: the image travels and grows over two seconds while the rest cross-fades. Then change the grid's:styleto{ viewTransitionName: 'product-image' }: the page swaps with no animation and the console readsUnexpected duplicate view-transition-name: product-image. - Set
app.pageTransition: { name: 'page', mode: 'out-in' }innuxt.config.tsand the.page-rules inapp.vue. Navigate: the old page fades out, then the new one fades in. Removemodeand navigate again: for a moment both pages are in the document, the new one below the old. - Copy the
@media (prefers-reduced-motion: reduce)block of the Code tab'sapp/app.vueinto yourapp.vuefrom tasks 1 and 2. In Chrome's DevTools, press Ctrl+Shift+P (Cmd+Shift+P on a Mac) and run "Emulate CSS prefers-reduced-motion: reduce". Navigate: the page swaps at once. Delete the block and navigate again: the fade is back, because CSS does not read the setting for you.