Skip to content

Page Transition

Animate changes between routes or views and carry shared elements between them.

Use it where a change of view is a change of place: opening a record from a list, stepping through a wizard, switching the panes of an app. It keeps people oriented between the two views. Don't animate every route of a content site: most navigations should feel instant, and a transition on each one taxes every click. Open a deployment in the preview, then go back.

Installation

npm i @youj/motion

import { PageTransition, SharedElement } from '@youj/motion/interactions'

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Usage

Wrap the view in PageTransition and give it a routeKey that changes with the view, usually the path. A new key animates from the old children to the new ones.

import { PageTransition } from '@youj/motion/interactions'

export function Pane({ path }: { path: string }) {
  return (
    <PageTransition routeKey={path} className="h-full">
      {path === '/deployments' ? <DeploymentList /> : <DeploymentDetail path={path} />}
    </PageTransition>
  )
}

Both pages stack in one grid cell while they overlap, so nothing is measured or positioned absolutely, and the container clips whatever travels out of it. Give the container a height when the pages differ in height. The first render doesn't animate.

Variants

fade-through, the default, fades the old page out quickly and the new one in just after, growing from 98% scale. slide pushes: both pages travel together. clip-reveal wipes the new page in over the old one, and curtain sweeps a solid panel across, swaps the page while it's covered, then sweeps away. Pick a variant, then switch tabs.

direction flips the travel of slide, clip-reveal and curtain. Pass "back" when the navigation goes back, like the Back button in the first preview or a tab to the left in this one. Color the curtain with curtainClassName, bg-accent by default.

Shared elements

Wrap an element in SharedElement with the same name on both pages, and it flies from its old position to its new one while the pages fade through. In the first preview, the commit message and the status badge fly from the list to the detail page and back.

import { SharedElement } from '@youj/motion/interactions'

// On the list
<SharedElement name={`title-${deploy.id}`}>{deploy.message}</SharedElement>

// On the detail page
<SharedElement name={`title-${deploy.id}`}>
  <h1>{deploy.message}</h1>
</SharedElement>

Shared elements only fly under fade-through. When the page itself travels, a flying element doubles the motion and the two copies visibly disagree, so under slide, clip-reveal and curtain they stay in place with their page.

Card to page

A shared element can change size and shape on the way, so a card's cover can grow into the header of the page it opens.

Set its corner radius in style, not with a class. Motion corrects a radius it knows about while the element scales, so the corners stay round in flight.

Next.js App Router

In the App Router, key the transition by usePathname() in a client component that your layout renders around children. The router swaps a page's content as soon as the route changes, so the leaving page would show the new route while it animates out. Freeze it: give each page the router context it was rendered with.

app/route-transition.tsx
'use client'

import { useContext, useRef, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { PageTransition } from '@youj/motion/interactions'

// Keeps a leaving page on the route it was rendered for while it animates out.
function FrozenRoute({ children }: { children: ReactNode }) {
  const context = useContext(LayoutRouterContext)
  const frozen = useRef(context).current
  return <LayoutRouterContext.Provider value={frozen}>{children}</LayoutRouterContext.Provider>
}

export function RouteTransition({ children }: { children: ReactNode }) {
  const pathname = usePathname()
  return (
    <PageTransition routeKey={pathname} className="min-h-dvh">
      <FrozenRoute>{children}</FrozenRoute>
    </PageTransition>
  )
}

next/dist/shared/lib/app-router-context.shared-runtime is an internal path of Next.js, not a public API. It can move in a minor release, so pin next and check this file when you upgrade. Next has no public way to freeze a leaving page.

app/layout.tsx
<body>
  <RouteTransition>{children}</RouteTransition>
</body>

Keep persistent chrome, like a sticky header, in the layout outside RouteTransition. The transition clips its pages, so a sticky element inside one would stick to the transition's box, not the viewport.

Reference

PropsrouteKey, children, variant… (7)

Props

PageTransition

PageTransition props
PropType
routeKeyRequiredstring

Changing this key transitions to the new children. Usually the route path.

–
childrenRequiredReactNode–
variantPageVariant
'fade-through''slide''clip-reveal''curtain'

default 'fade-through'

'fade-through'
directionPageDirection
'forward''back'

Navigation direction; flips slide / clip-reveal / curtain. Forward travels toward the reading start, so slide and curtain mirror under RTL.

default 'forward'

'forward'
curtainClassNamestring

Classes for the curtain panel.

default 'bg-accent'

'bg-accent'
classNamestring–

SharedElement

SharedElement props
PropType
nameRequiredstring

Matching names on two pages morph into each other across a PageTransition.

–
Other props pass through: extends HTMLMotionProps<'div'>.
MotionFade-through out, fade-through in, fade-through scale… · reduced motion

Motion

Motion tokens
MomentTokens
Fade-through outopacity
duration.fastease.exit

140ms · cubic-bezier(0.4, 0, 1, 1)

140mscubic-bezier(0.4, 0, 1, 1)
Fade-through inopacity, after an instant delay
duration.baseease.enter

220ms · cubic-bezier(0.16, 1, 0.3, 1)

220mscubic-bezier(0.16, 1, 0.3, 1)
Fade-through scale0.98 to 1
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Slideboth pages, a full width
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Clip reveal wipeclip-path
duration.slowease.emphasized

340ms · cubic-bezier(0.6, 0, 0.1, 1)

340mscubic-bezier(0.6, 0, 0.1, 1)
Clip reveal rise32px
duration.deliberateease.emphasized

560ms · cubic-bezier(0.6, 0, 0.1, 1)

560mscubic-bezier(0.6, 0, 0.1, 1)
Clip reveal exitthe old page dims and drifts 16px
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)
Curtainin on emphasized, a short hold, out on standard
ease.emphasized

cubic-bezier(0.6, 0, 0.1, 1) · 2 × slow + instant

cubic-bezier(0.6, 0, 0.1, 1)2 × slow + instant
Shared elementflies between positions
spring.smooth

0.36s, bounce 0

0.36s, bounce 0

Reduced motion

Every variant becomes a short crossfade on the fast token, with no travel and no curtain. Shared elements jump to their new place.

Reduced motion

Every variant becomes the same short crossfade, so the change of view is still visible, without travel, wipes or a curtain. Shared elements move to their new place instantly.

AccessibilityARIA, focus

Accessibility

  • A transition doesn't move focus. After a navigation, focus belongs on the new page, not on an element that has just left: the first preview moves it forward to the Back button, and back to the item that was opened.
  • The curtain panel is aria-hidden and ignores the pointer.
  • The leaving page stays in the document only while it animates out, which is at most a few hundred milliseconds at normal speed.