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
import { useEffect, useState } from "react";
import PageTransition from "@/components/ui/page-transition";
const PAGES = [
{ hash: "#home", title: "Make it move.", tone: "bg-kine-bg" },
{ hash: "#work", title: "Selected work", tone: "bg-[#16181b]" },
{ hash: "#about", title: "The anti-jank promise", tone: "bg-kine-surface" },
];
/** Three stand-in pages switched with state — the transition does no routing of its own. */
export default function PageTransitionDemo({ fullscreen }: { fullscreen?: boolean }) {
const [route, setRoute] = useState("#home");
// Below md the transition steps aside and lets the link change the hash.
useEffect(() => {
const onHash = () => setRoute(window.location.hash || "#home");
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
const page = PAGES.find((p) => route.endsWith(p.hash)) ?? PAGES[0]!;
const content = (
<div className={`flex min-h-full flex-col gap-10 p-8 md:p-12 ${page.tone} ${fullscreen ? "min-h-dvh" : "min-h-[520px]"}`}>
<nav className="flex gap-6">
{PAGES.map((p) => (
<a
key={p.hash}
href={p.hash}
className={`font-kine-body text-sm uppercase tracking-widest ${p === page ? "text-kine-primary" : "text-kine-accent/60 hover:text-kine-accent"}`}
>
{p.hash.slice(1)}
</a>
))}
</nav>
<h2 className="font-kine-display text-4xl uppercase text-kine-accent md:text-6xl">{page.title}</h2>
</div>
);
if (fullscreen) {
return (
<PageTransition routeKey={route} onNavigate={(href) => setRoute(new URL(href, window.location.href).hash)}>
{content}
</PageTransition>
);
}
return (
<div className="w-full min-w-0 space-y-3">
<div className="h-[520px] w-full overflow-hidden rounded-card border border-line bg-[#0b0c0e]">
<PageTransition
contained
routeKey={route}
onNavigate={(href) => setRoute(new URL(href, window.location.href).hash)}
>
{content}
</PageTransition>
</div>
<p className="text-center text-xs text-faint">
Click a link — the page shrinks into a tile, swaps, and zooms back out
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| routeKey | string | — | 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. |
| contained | boolean | false | Play inside the nearest box instead of the viewport. |
| children | ReactNode | — | The routed content. Needs a client-side router: the old page has to stay mounted while the new one renders. |