Animating Navigation

Put a transition around router-view with the slot API, choose direction from the route, and stop the scroll reset fighting it.

Syntax<router-view v-slot="{ Component, route }"><transition mode="out-in"><component :is="Component" :key="route.path" /></transition></router-view>

A navigation swaps one component for another, which is exactly what <Transition> animates. The obstacle is that <router-view /> renders the matched component itself. Wrapping that tag in a transition gives the transition a child whose component type never changes, so nothing animates.

The slot form

<router-view> can instead hand you what it would have rendered, and you decide what goes around it:

<router-view v-slot="{ Component, route }">
  <transition name="fade" mode="out-in">
    <component :is="Component" />
  </transition>
</router-view>

v-slot="{ Component }" is shorthand for v-slot:default="{ Component }", which can also be written #default. :is is shorthand for v-bind:is. Use mode="out-in" unless you have positioned the views on top of each other, or two pages will be on screen at once.

Direction

The second slot prop, route, is the resolved destination, so the transition name can come from the route itself. Set meta.transition on a route record for a fixed one, or set it in router.afterEach((to, from) => ...) by comparing how many segments each path has: going deeper slides one way, coming back slides the other.

Scroll

scrollBehavior(to, from, savedPosition), an option on createRouter, runs on every navigation. savedPosition is set only for a back or forward press, and returning it restores where the user was. Returning { top: 0 } straight away fights the animation: the page jumps while the outgoing view is still leaving. Return a promise that resolves after the leave duration instead.

What trips people up

The routed component needs a single root element, or the transition has nothing to put a class on.

Example

Example · vue
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <nav>
    <router-link to="/">Home</router-link>
    <router-link to="/orders">Orders</router-link>
  </nav>

  <router-view v-slot="{ Component, route }">
    <transition
      :name="(route.meta.transition as string | undefined) ?? 'fade'"
      mode="out-in"
    >
      <component :is="Component" :key="route.path" />
    </transition>
  </router-view>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 160ms ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

When to use it

  • Fade between top-level pages so a navigation does not flash white.
  • Slide forward into a detail route and back out of it based on path depth.
  • Restore the scroll position when the user presses the browser back button.
  • Delay the scroll reset so it does not happen while the old page is leaving.

More examples

Typed route meta and direction from path depth

RouteMeta is an open interface, so augmenting it once makes route.meta.transition a string | undefined everywhere and removes the cast from the template. The afterEach hook writes the name just before the view swaps: moving from /orders to /orders/42 goes deeper and slides left, pressing back slides right. Set meta.transition on an individual route record when one page wants a fixed animation regardless of where the user came from.

Example · ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import OrdersView from '@/views/OrdersView.vue'
import OrderView from '@/views/OrderView.vue'

declare module 'vue-router' {
  interface RouteMeta {
    transition?: string
  }
}

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', name: 'home', component: HomeView },
    { path: '/orders', name: 'orders', component: OrdersView },
    { path: '/orders/:id', name: 'order', component: OrderView }
  ]
})

router.afterEach((to, from) => {
  const toDepth = to.path.split('/').length
  const fromDepth = from.path.split('/').length
  to.meta.transition = toDepth < fromDepth ? 'slide-right' : 'slide-left'
})

export default router

Holding the scroll reset until the leave has finished

scrollBehavior receives the two route objects and savedPosition, which is present only on a browser back or forward press; returning it reproduces native behaviour. An anchor link scrolls to an element with el, where top and left become offsets from that element. Everything else scrolls to the top, but only after LEAVE_MS, which should match the leave duration in the CSS — otherwise the page yanks to the top while the old view is still visible.

Example · ts
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'

const LEAVE_MS = 180

export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    }

    if (to.hash) {
      return { el: to.hash, behavior: 'smooth' }
    }

    return new Promise<{ top: number }>((resolve) => {
      setTimeout(() => {
        resolve({ top: 0 })
      }, LEAVE_MS)
    })
  }
})

The two slide directions in CSS

The two names the afterEach hook produces, written out. Each direction is a mirror of the other: slide-left pushes the old page out to the left and brings the new one in from the right. Import this file once in main.ts rather than per view, because the classes land on the routed component's root element and have to be reachable from wherever it renders. The media query keeps the opacity change and drops the movement for users who asked for less of it. Travel of 24px is deliberate: a full-width slide takes longer than it is worth.

Example · css
/* src/assets/transitions.css */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
  transition: opacity 180ms ease, transform 180ms ease;
}

.slide-left-enter-from {
  opacity: 0;
  transform: translateX(24px);
}

.slide-left-leave-to {
  opacity: 0;
  transform: translateX(-24px);
}

.slide-right-enter-from {
  opacity: 0;
  transform: translateX(-24px);
}

.slide-right-leave-to {
  opacity: 0;
  transform: translateX(24px);
}

@media (prefers-reduced-motion: reduce) {
  .slide-left-enter-from,
  .slide-left-leave-to,
  .slide-right-enter-from,
  .slide-right-leave-to {
    transform: none;
  }
}

Discussion

  • Be the first to comment on this lesson.