Page Transition

Animated transitions between pages, built on view transitions, for Vue Router and Nuxt.

<script setup lang="ts">
import type { PageTransitionEffect } from '@/components/animate-ui/components/page-transition'
import { startTransition } from 'motion-v'
import { ref, watch } from 'vue'
import { PageTransition, PageTransitionShared } from '@/components/animate-ui/components/page-transition'

const props = withDefaults(
  defineProps<{ effect?: PageTransitionEffect, shared?: boolean | string, picker?: boolean | string }>(),
  { effect: 'slide', shared: true, picker: true },
)

const PAGES = [
  {
    title: 'Home',
    text: 'Animated primitives for Radix UI, Base UI and React Aria.',
    tone: 'bg-sky-100 dark:bg-sky-950',
  },
  {
    title: 'Docs',
    text: 'Swap the import and your shadcn/ui components animate.',
    tone: 'bg-amber-100 dark:bg-amber-950',
  },
  {
    title: 'Blog',
    text: 'Animate UI 2.0 is out, with a CLI and a skill.',
    tone: 'bg-emerald-100 dark:bg-emerald-950',
  },
]

const EFFECTS: PageTransitionEffect[] = ['fade', 'slide', 'push', 'zoom', 'iris', 'wipe', 'bands', 'stack', 'flow', 'chop', 'random']

const page = ref(0)
const chosen = ref<PageTransitionEffect>(props.effect)
watch(() => props.effect, (effect) => {
  chosen.value = effect
})

const on = (value: boolean | string) => value === true || value === 'true'

function go(next: number) {
  if (next === page.value)
    return
  const types = [next > page.value ? 'nav-forward' : 'nav-back']
  startTransition(() => {
    page.value = next
  }, { types })
}
</script>

<template>
  <div class="flex w-full max-w-md flex-col gap-3">
    <div
      v-if="on(picker)"
      role="radiogroup"
      aria-label="Effect"
      class="flex flex-wrap justify-center gap-1"
    >
      <button
        v-for="name in EFFECTS"
        :key="name"
        role="radio"
        :aria-checked="chosen === name"
        class="rounded-md border px-2.5 py-1 font-mono text-xs text-muted-foreground transition-colors hover:text-foreground aria-checked:border-foreground aria-checked:bg-foreground aria-checked:text-background"
        @click="chosen = name"
      >
        {{ name }}
      </button>
    </div>
    <div class="w-full overflow-hidden rounded-xl border bg-background">
      <nav class="flex gap-1 border-b p-2">
        <button
          v-for="(item, index) in PAGES"
          :key="item.title"
          :aria-current="index === page ? 'page' : undefined"
          class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:text-foreground"
          @click="go(index)"
        >
          {{ item.title }}
        </button>
      </nav>
      <PageTransition
        :effect="chosen"
        :page-key="page"
        :class="`relative flex h-64 flex-col justify-end gap-1.5 p-6 ${PAGES[page]!.tone}`"
      >
        <PageTransitionShared
          v-if="on(shared) && page < 2"
          name="page-transition-demo-cover"
          :class="page === 0
            ? 'absolute top-6 right-6 size-16 rounded-xl bg-primary'
            : 'absolute inset-x-6 top-6 h-20 rounded-lg bg-primary'"
        />
        <h2 class="text-2xl font-medium">
          {{ PAGES[page]!.title }}
        </h2>
        <p class="text-sm text-muted-foreground">
          {{ PAGES[page]!.text }}
        </p>
      </PageTransition>
    </div>
  </div>
</template>

Effects

fadeA crossfade through a light blur.
slideSlides in 60px and fades, towards where you go.
pushThe new page pushes the old one out.
zoomThe old page grows away, the new one settles in.
irisOpens as a circle from where you clicked.
wipeA clean edge reveals the new page.
bandsFive staggered bands reveal the new page.
stackLands on the old page, which sinks back.
flowA quick slide through a strong motion blur.
chopA diagonal cut reveals the new page.

random picks another of these effects at each navigation, the same for the page that leaves and the one that arrives.

Installation

npx shadcn-vue@latest add https://animate-ui.com/r/vue/components-page-transition.json
pnpm dlx shadcn-vue@latest add https://animate-ui.com/r/vue/components-page-transition.json
yarn dlx shadcn-vue@latest add https://animate-ui.com/r/vue/components-page-transition.json
bun x shadcn-vue@latest add https://animate-ui.com/r/vue/components-page-transition.json

It plays in browsers with view transitions (Chromium 125+, recent Safari and Firefox), through Motion for Vue's AnimateView. Elsewhere, pages change without animation.

Usage

Run the router's navigations in view transitions with routeTransitions, once:

src/main.ts
import { routeTransitions } from '@/components/animate-ui/components/page-transition';

const router = createRouter({ history: createWebHistory(), routes });
routeTransitions(router);

Then wrap the content that changes from one page to the next, in the layout, so the header and navigation around it stay still:

src/App.vue
<script setup lang="ts">
import { PageTransition } from '@/components/animate-ui/components/page-transition';
</script>

<template>
  <Header />
  <RouterView v-slot="{ Component }">
    <PageTransition effect="slide">
      <component :is="Component" />
    </PageTransition>
  </RouterView>
</template>

With Nuxt, call it in a plugin (routeTransitions(useRouter()) in plugins/page-transition.client.ts), leave Nuxt's own experimental.viewTransition off, and wrap <NuxtPage /> the same way.

Navigations play forward; going back in the history (the browser's back button, router.back()) plays back. The page is the route's path; pass page-key to identify it otherwise.

One effect per navigation

A transition type named after an effect plays it for that navigation, whatever effect says. Navigate in your own transition to set it, for tabs that push sideways while the rest of the site stacks:

import { startTransition } from 'motion-v';

startTransition(() => router.push('/pricing/yearly'), {
  types: ['push', 'nav-forward'],
});

Shared elements

An element on both pages, like a card that becomes a page header, morphs from one to the other with PageTransitionShared. Give it the same name on both pages, unique on each page:

<PageTransitionShared :name="`cover-${post.id}`" class="h-20 rounded-lg bg-primary" />

Animation

  • Each page is a snapshot the browser animates: the old page plays its exit and the new one its entrance, on Animate UI's curves (ease.out to arrive, a quicker ease.leave to go). Both stay inside the page's box.
  • Direction comes from the nav-back and nav-forward transition types: routeTransitions sets nav-back when the history goes back to the entry before, nav-forward otherwise.
  • Other view transitions on the same pages (a Theme Toggler's wipe) are left alone: the old page is only hidden during a page change.
  • View transitions aren't interruptible: a click during one waits for it to end, so the effects stay short (0.3s to 0.7s).
  • Under reduced motion, every effect becomes the crossfade, blur included.

API Reference

PageTransition

PropTypeDefault
effect?
'fade' | 'slide' | 'push' | 'zoom' | 'iris' | 'wipe' | 'bands' | 'stack' | 'flow' | 'chop' | 'random''fade'
page-key?
string | numberthe route's path

PageTransitionShared

PropTypeDefault
name
string-

routeTransitions

routeTransitions(router: Router): runs each route change of router in a view transition typed nav-forward or nav-back. It returns a function that stops it.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/23/2026