Skip to content
jswnthui

Motion

Page Transition

Route changes as choreography: the page shrinks into a tilted tile, a second tile waits beside it, and once the new page is in they merge and zoom back to full size.

Make it move.

Click a link — the page shrinks into a tile, swaps, and zooms back out

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/page-transition.tsx
    'use client'
    
    import { useEffect, useRef, type ReactNode } from 'react'
    import gsap from 'gsap'
    import { useIsMobile } from '@/lib/use-is-mobile'
    import { cn } from '@/lib/utils'
    
    // Desktop-only choreographed route transition (mobile keeps the app's own
    // instant navigation untouched — see the click-interception effect below).
    // Four phases:
    //   1. Leave  — the real current page shrinks to a tilted 10% x 10% tile
    //               (Tile A, left slot) while a decorative "incoming" tile
    //               (Tile B) appears shrunk at the right slot.
    //   2. Hold   — onNavigate() fires; both tiles sit still until the
    //               destination has actually rendered (routeKey changes).
    //   3. Merge  — the content wrapper (now showing the real destination page,
    //               still tiny/tilted at the left slot) travels into Tile B's
    //               slot; Tile B fades out once it arrives.
    //   4. Reveal — the content wrapper zooms from that slot up to full size.
    //
    // It does no routing of its own: hand it whatever your router does. With
    // Next, that is `onNavigate={router.push}` and `routeKey={pathname}`; with
    // plain React state, a setter and the current view's key. It needs the old
    // page to stay mounted while the new one renders in its place, so it wants
    // a client-side router, not full page loads.
    const SHRINK_SCALE = 0.1
    const TILT_DEG = 8
    const SLOT_OFFSET = 0.22
    
    const CLEAR_PROPS =
      'position,top,left,right,bottom,overflow,zIndex,pointerEvents,transform,willChange,transformOrigin'
    
    interface PageTransitionProps {
      children: ReactNode
      /** Changes once the destination page has rendered — e.g. the pathname. */
      routeKey: string
      /** Called with the link's href once the leave animation has finished. */
      onNavigate: (href: string) => void
      /** Play inside the nearest box instead of the viewport, for showing it in
          a card. The box scrolls its own content back to the top instead of the
          window. */
      contained?: boolean
      className?: string
    }
    
    export default function PageTransition({
      children,
      routeKey,
      onNavigate,
      contained = false,
      className,
    }: PageTransitionProps) {
      const frameRef = useRef<HTMLDivElement>(null)
      const contentRef = useRef<HTMLDivElement>(null)
      const incomingRef = useRef<HTMLDivElement>(null)
      const isMobile = useIsMobile()
      const isMobileRef = useRef(false)
      const isTransitioningRef = useRef(false)
      const pendingHrefRef = useRef<string | null>(null)
      // Read through refs by the mount-once click handler below.
      const navigateRef = useRef(onNavigate)
      const routeKeyRef = useRef(routeKey)
    
      useEffect(() => {
        navigateRef.current = onNavigate
        routeKeyRef.current = routeKey
      }, [onNavigate, routeKey])
    
      useEffect(() => {
        isMobileRef.current = isMobile
      }, [isMobile])
    
      /** Width of whatever the transition plays across: the box, or the screen. */
      const frameWidth = () => (contained ? (frameRef.current?.clientWidth ?? 0) : window.innerWidth)
    
      const beginTransition = (href: string) => {
        const frame = frameRef.current
        const content = contentRef.current
        const incoming = incomingRef.current
        if (!frame || !content || !incoming || isTransitioningRef.current) return
    
        isTransitioningRef.current = true
        pendingHrefRef.current = href
        if (contained) frame.scrollTo(0, 0)
        else window.scrollTo(0, 0)
    
        // Freezes the current page into a frame-sized fixed box — since scroll
        // was just reset to 0, this box shows exactly the top of the page
        // (predictable regardless of where the click happened), and a uniform
        // `scale` shrinks it to exactly SHRINK_SCALE of the frame, no manual
        // pixel math needed. The transform this applies also makes `content`
        // the containing block for any position:fixed descendants.
        gsap.set(content, {
          position: contained ? 'absolute' : 'fixed',
          top: 0,
          left: 0,
          right: 0,
          bottom: 0,
          overflow: 'hidden',
          zIndex: 40,
          pointerEvents: 'none',
          transformOrigin: 'center center',
        })
        gsap.set(incoming, { xPercent: -50, yPercent: -50, scale: 0, opacity: 0 })
    
        const leftX = -frameWidth() * SLOT_OFFSET
        const rightX = frameWidth() * SLOT_OFFSET
    
        gsap
          .timeline({ onComplete: () => navigateRef.current(href) })
          .to(content, { scale: SHRINK_SCALE, rotation: -TILT_DEG, x: leftX, duration: 0.7, ease: 'power3.inOut' }, 0)
          .to(
            incoming,
            { scale: SHRINK_SCALE, opacity: 1, rotation: TILT_DEG, x: rightX, duration: 0.55, ease: 'power3.out' },
            0.15,
          )
      }
    
      // Phase 3/4 — fires once the destination has actually rendered: this same
      // instance persists across the navigation (it wraps the routed content),
      // so routeKey changing is exactly the signal that `children` now holds the
      // real destination page.
      useEffect(() => {
        if (!pendingHrefRef.current) return
        const content = contentRef.current
        const incoming = incomingRef.current
        if (!content || !incoming) return
    
        pendingHrefRef.current = null
        const rightX = frameWidth() * SLOT_OFFSET
    
        gsap
          .timeline({
            onComplete: () => {
              gsap.set(content, { clearProps: CLEAR_PROPS })
              gsap.set(incoming, { scale: 0, opacity: 0 })
              isTransitioningRef.current = false
            },
          })
          .to(content, { x: rightX, rotation: TILT_DEG, duration: 0.4, ease: 'power2.inOut' })
          .to(incoming, { opacity: 0, duration: 0.15, ease: 'power1.out' }, '-=0.1')
          .to(content, { x: 0, rotation: 0, scale: 1, duration: 0.75, ease: 'power3.out' })
        // frameWidth only reads a ref and a prop that does not change mid-flight.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [routeKey])
    
      // Capture-phase click interception, before any router's own <a> handler
      // (which runs in the bubble phase), so a plain preventDefault() is enough
      // to make it bail out of its own navigation — without stopPropagation,
      // which would also swallow other onClick handlers on the same element.
      // Contained, only links inside the frame are taken over.
      useEffect(() => {
        const handleClick = (e: MouseEvent) => {
          if (isMobileRef.current || isTransitioningRef.current) return
          if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
    
          const anchor = (e.target as HTMLElement | null)?.closest?.('a')
          if (!anchor) return
          if (contained && !frameRef.current?.contains(anchor)) return
          if (anchor.target === '_blank' || anchor.hasAttribute('download')) return
          if (anchor.origin !== window.location.origin) return
          // A link to where we already are — the whole href, or just the
          // pathname when routeKey is one — is left to the browser.
          const href = anchor.pathname + anchor.search + anchor.hash
          if (href === routeKeyRef.current || anchor.pathname === routeKeyRef.current) return
    
          e.preventDefault()
          beginTransition(href)
        }
    
        document.addEventListener('click', handleClick, true)
        return () => document.removeEventListener('click', handleClick, true)
        // Mount-once by design — beginTransition reads every dynamic value it
        // needs through refs.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [contained])
    
      return (
        <div
          ref={frameRef}
          className={cn(contained && 'relative h-full overflow-y-auto overflow-x-hidden', className)}
        >
          <div ref={contentRef}>{children}</div>
          <div
            ref={incomingRef}
            className={cn(
              'pointer-events-none left-1/2 top-1/2 z-[45] flex h-[10%] w-[10%] items-center justify-center rounded-2xl border-2 border-dashed border-kine-primary/60 bg-[#131417] opacity-0',
              contained ? 'absolute' : 'fixed h-[10vh] w-[10vw]',
            )}
          >
            <span className="h-2 w-2 rounded-full bg-kine-primary" />
          </div>
        </div>
      )
    }
    lib/use-is-mobile.ts
    "use client";
    
    import { useEffect, useState } from "react";
    
    const QUERY = "(max-width: 767px)";
    
    /** True below Tailwind's `md` breakpoint (767px). Starts `false` on the
     * server/first paint (no `matchMedia` there) and corrects on mount — the
     * same convention as usePrefersReducedMotion. For components that render a
     * genuinely different layout on mobile, not just a slowed-down one. */
    export function useIsMobile() {
      const [isMobile, setIsMobile] = useState(false);
    
      useEffect(() => {
        const query = window.matchMedia(QUERY);
        setIsMobile(query.matches);
    
        const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
        query.addEventListener("change", handleChange);
        return () => query.removeEventListener("change", handleChange);
      }, []);
    
      return isMobile;
    }
    styles/kinematics.css
    /* Kinematic Studio's palette and type, for the components ported from its
       site. Namespaced as kine-* so they sit beside this library's own tokens —
       Kinematics calls its off-white "accent", which here is already the blue.
       Import this from the stylesheet that imports Tailwind, so the @theme
       block turns into utilities: bg-kine-bg, text-kine-primary/70,
       font-kine-display and so on. */
    @theme {
      --color-kine-bg: #0f1113;
      --color-kine-primary: #ceff00;
      --color-kine-surface: #2b2e34;
      --color-kine-accent: #e2e8f0;
    
      /* Audiowide for headlines, Geo for body copy. Geo breaks up at small
         sizes, so tiny labels use the plain sans instead. */
      --font-kine-display: "Audiowide", ui-sans-serif, system-ui, sans-serif;
      --font-kine-body: "Geo", ui-sans-serif, system-ui, sans-serif;
    
      /* Morni, the restaurant client in the Emoji Cursor demo. */
      --font-morni-brand: "Modak", ui-sans-serif, system-ui, sans-serif;
      --font-morni-body: "Poppins", ui-sans-serif, system-ui, sans-serif;
    }
    
    /* Manifesto emoji markers — the box grows from square to rectangle as scroll
       reaches it; once mostly grown, the emoji starts rolling, paused until then
       via animation-play-state set inline. */
    @keyframes manifesto-emoji-roll {
      to {
        transform: rotate(360deg);
      }
    }
    
    /* Expertise cards — small, always-looping visuals tying each card's
       animation to its own subject (motion, layout, load speed, ranking,
       language). CSS keyframes rather than GSAP, since they run for as long as
       the card is mounted. */
    @keyframes expertise-dash {
      to {
        stroke-dashoffset: -24;
      }
    }
    .expertise-flow-line {
      stroke-dasharray: 6 6;
      animation: expertise-dash 1s linear infinite;
    }
    
    @keyframes expertise-block-a {
      0%, 100% { width: 85%; }
      50% { width: 45%; }
    }
    @keyframes expertise-block-b {
      0%, 100% { width: 55%; }
      50% { width: 90%; }
    }
    @keyframes expertise-block-c {
      0%, 100% { width: 70%; }
      50% { width: 30%; }
    }
    .expertise-block-a { animation: expertise-block-a 3.2s ease-in-out infinite; }
    .expertise-block-b { animation: expertise-block-b 3.2s ease-in-out infinite 0.3s; }
    .expertise-block-c { animation: expertise-block-c 3.2s ease-in-out infinite 0.6s; }
    
    @keyframes expertise-fill {
      0% { width: 6%; opacity: 0.5; }
      70% { width: 100%; opacity: 1; }
      85% { width: 100%; opacity: 1; }
      100% { width: 6%; opacity: 0.5; }
    }
    .expertise-fill-bar {
      animation: expertise-fill 2.2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
    }
    
    @keyframes expertise-rank-bar-1 { 0%, 100% { height: 30%; } 50% { height: 80%; } }
    @keyframes expertise-rank-bar-2 { 0%, 100% { height: 55%; } 50% { height: 25%; } }
    @keyframes expertise-rank-bar-3 { 0%, 100% { height: 40%; } 50% { height: 95%; } }
    @keyframes expertise-rank-bar-4 { 0%, 100% { height: 70%; } 50% { height: 50%; } }
    .expertise-rank-1 { animation: expertise-rank-bar-1 2.4s ease-in-out infinite; }
    .expertise-rank-2 { animation: expertise-rank-bar-2 2.4s ease-in-out infinite 0.15s; }
    .expertise-rank-3 { animation: expertise-rank-bar-3 2.4s ease-in-out infinite 0.3s; }
    .expertise-rank-4 { animation: expertise-rank-bar-4 2.4s ease-in-out infinite 0.45s; }
    
    @keyframes expertise-lang-cross {
      0%, 45% { opacity: 1; transform: translateY(0); }
      50%, 95% { opacity: 0; transform: translateY(-6px); }
      100% { opacity: 1; transform: translateY(0); }
    }
    @keyframes expertise-lang-cross-alt {
      0%, 45% { opacity: 0; transform: translateY(6px); }
      50%, 95% { opacity: 1; transform: translateY(0); }
      100% { opacity: 0; transform: translateY(6px); }
    }
    .expertise-lang-a { animation: expertise-lang-cross 3.6s ease-in-out infinite; }
    .expertise-lang-b { animation: expertise-lang-cross-alt 3.6s ease-in-out infinite; }

Props

PropTypeDefaultDescription
routeKeystring—Changes once the new page has rendered — the pathname, say.
onNavigate(href: string) => void—Your router's navigate, called once the page has shrunk away.
containedbooleanfalsePlay inside the nearest box instead of the viewport.
childrenReactNode—The routed content. Needs a client-side router: the old page has to stay mounted while the new one renders.