Page Transition
A click on a link changes the whole screen at once: the page leaves rising and fading, the next one arrives from below.
$npx shadcn@latest add https://framebrick.co/r/page-transition.jsonFrom the Semono template, see it in context.
Follow a link: the whole page leaves and the next one arrives, header and footer with it.
import Link from "next/link";
import { PageTransition } from "@/components/ui/page-transition";
const link =
"inline-flex h-9 items-center justify-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50";
/* In your app you mount <PageTransition /> once in the root layout and every link changes. Here `scope` keeps it to these
two links, so the rest of this page navigates as it always does. */
export default function PageTransitionDemo() {
return (
<div data-page-transition-demo className="flex max-w-xs flex-col items-center gap-4 text-center">
<PageTransition scope="[data-page-transition-demo]" />
<p className="text-sm text-muted-foreground">Follow a link: the whole page leaves and the next one arrives, header and footer with it.</p>
<div className="flex gap-3">
<Link href="/components/marquee" className={link}>
Marquee
</Link>
<Link href="/components/text-appear" className={link}>
Text Appear
</Link>
</div>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/page-transition.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/page-transition.tsx."use client"; import * as React from "react"; import { usePathname, useRouter } from "next/navigation"; /* * Page Transition — a click on a link to another page of the site changes the whole screen at once, header and footer * included (from the Semono template, where it is Framer's own page change). The page that is leaving fades out while it * rises 30%, the new one fades in from 30% lower, each over 0.6s on cubic-bezier(0.73, 0, 0.33, 1), the new one starting * 0.5s later: both are on screen for 0.1s, 1.1s in all. It runs inside the browser's View Transition API, so the page * before and the page after are two pictures that the browser moves; the screen cannot be clicked until it is done. * * Mount it once, in the root layout (it renders only its own CSS). It waits for the new page: router.push runs inside the * transition and the new pathname lets it go on, after `timeout` at the latest, so a page that never arrives cannot freeze * the screen. It does nothing — the browser just navigates — for a modifier key or a click that is not the main button, a * link with a target other than _self, a download, another site, the same pathname, a browser without * document.startViewTransition and visitors who prefer reduced motion. Back and Forward swap the page without it. * * Differences from Semono, each with its reason: * - the rules apply only while this component runs its own transition (an attribute on <html>), so an app that has * transitions of its own is not touched; * - a link, or anything around it, with data-no-transition is left alone — use it for a link your own onClick handles, * because this listens before React does and would take that click; * - `scope` limits it to the links inside a selector, which is how the demo shows it without taking over the page; * - the transition's promises are handled, so a skipped transition leaves no error in the console. * It uses next/navigation, so it is for the App Router (the one place in the library that is not plain React + Motion). */ const CSS = `:root[data-page-transition]::view-transition-group(root){animation-duration:250ms} :root[data-page-transition]::view-transition-old(root),:root[data-page-transition]::view-transition-new(root){mix-blend-mode:normal;backface-visibility:hidden;animation-duration:600ms;animation-timing-function:cubic-bezier(0.73,0,0.33,1);animation-fill-mode:both} :root[data-page-transition]::view-transition-old(root){animation-name:fb-page-leave} :root[data-page-transition]::view-transition-new(root){animation-name:fb-page-enter;animation-delay:500ms} @keyframes fb-page-leave{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-30%)}} @keyframes fb-page-enter{from{opacity:0;transform:translateY(30%)}to{opacity:1;transform:translateY(0)}} @media (prefers-reduced-motion:reduce){:root[data-page-transition]::view-transition-group(*),:root[data-page-transition]::view-transition-old(*),:root[data-page-transition]::view-transition-new(*){animation:none!important}}`; export type PageTransitionProps = { /** CSS selector: only links inside an element that matches start a page change. Every link of the site by default. */ scope?: string; /** Milliseconds to wait for the new page before the transition goes on without it. */ timeout?: number; }; export function PageTransition({ scope, timeout = 2500 }: PageTransitionProps) { const router = useRouter(); const pathname = usePathname(); const arrived = React.useRef<(() => void) | null>(null); const latest = React.useRef(0); /* The new page is on screen: let the transition start. The cleanup lets it go on too when this component is part of the page that is being left (the demo is) and so is unmounted before it could see the new pathname. */ React.useEffect(() => { const release = () => { arrived.current?.(); arrived.current = null; }; release(); return release; }, [pathname]); React.useEffect(() => { /* Capture phase, before React and the router see the click: the router then finds it already handled and leaves the navigation to the transition. */ function onClick(event: MouseEvent) { if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; if (!("startViewTransition" in document) || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const link = (event.target as Element | null)?.closest("a[href]"); if (!(link instanceof HTMLAnchorElement)) return; if ((link.target && link.target !== "_self") || link.hasAttribute("download") || link.closest("[data-no-transition]")) return; if (scope && !link.closest(scope)) return; const url = new URL(link.href, window.location.href); if (url.origin !== window.location.origin || url.pathname === window.location.pathname) return; event.preventDefault(); const root = document.documentElement; const run = ++latest.current; root.setAttribute("data-page-transition", ""); const transition = document.startViewTransition( () => new Promise<void>((resolve) => { arrived.current?.(); arrived.current = resolve; router.push(url.pathname + url.search + url.hash); window.setTimeout(resolve, timeout); }), ); /* A transition that a newer one skips rejects these two; nothing here depends on them. The rules go when the newest transition is done, not when a skipped one is. */ transition.ready.catch(() => {}); transition.updateCallbackDone.catch(() => {}); const done = () => { if (latest.current === run) root.removeAttribute("data-page-transition"); }; transition.finished.then(done, done); } document.addEventListener("click", onClick, true); return () => document.removeEventListener("click", onClick, true); }, [router, scope, timeout]); /* One <style> that React 19 moves to the head and keeps there: the rules must still exist when the browser builds the transition, which is after the update — and so after a page that holds this component (the demo's) has gone. */ return ( <style href="framebrick-page-transition" precedence="default"> {CSS} </style> ); }
Usage
// app/layout.tsx — once, and every link of the site changes
import { PageTransition } from "@/components/ui/page-transition";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<PageTransition />
{children}
</body>
</html>
);
}
// a link your own onClick handles keeps its own behaviour
<Link href="/menu" data-no-transition onClick={openMenu}>Menu</Link>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| scope | string | — | CSS selector: only links inside an element that matches start a page change. Every link of the site by default. |
| timeout | number | 2500 | Milliseconds to wait for the new page before the transition goes on without it, so a page that never arrives cannot freeze the screen. |
- The page that is leaving fades out while it rises 30% over 0.6 s; the new one fades in from 30% lower over 0.6 s, starting 0.5 s later, so both are on screen for 0.1 s and the change takes 1.1 s. Both run on cubic-bezier(0.73, 0, 0.33, 1). The whole screen is one picture, header and footer with it, so it cannot be clicked until the change is done — as on Framer.
- It renders only its own CSS and listens for clicks on links. It uses the browser's View Transition API and next/navigation, so it is for the App Router: router.push runs inside the transition and the new pathname lets it go on.
- It does nothing — the browser just navigates — for a modifier key or a click that is not the main button, a link with a target other than _self, a download, another site, the same pathname, a browser without document.startViewTransition and visitors who prefer reduced motion. Back and Forward swap the page without it.
- It listens before React does, so a link whose own onClick should handle the click needs data-no-transition, on the link or on anything around it.
- The rules apply only while this component runs its own transition (an attribute on <html>), so an app with view transitions of its own is not touched. Semono's apply to every transition.
- The demo uses `scope`, which keeps the effect to its two links so the rest of this page navigates as it always does.
- Why not React's <ViewTransition>? The stable react package does not export it (Next resolves its own canary build in the App Router) and it needs canary types, so a file you copy in could not count on it. document.startViewTransition works wherever the browser has it.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React component called PageTransition for the Next.js App Router (no animation library). It renders only a <style> and listens for clicks.
- A "use client" component using useRouter and usePathname from next/navigation. Props: scope (a CSS selector, optional) and timeout (ms, default 2500).
- Add a click listener on document in the capture phase. Ignore the click when event.defaultPrevented, the button is not 0, a modifier key is held, document.startViewTransition does not exist, or matchMedia("(prefers-reduced-motion: reduce)") matches. Find the link with event.target.closest("a[href]"); ignore it when its target is not _self, it has a download attribute, it or an ancestor has data-no-transition, a scope is given and the link is not inside it, its origin is not the page's, or its pathname equals the current one.
- Otherwise call event.preventDefault(), set a data-page-transition attribute on <html>, and call document.startViewTransition with a callback that returns a Promise: it stores its resolve in a ref, calls router.push(pathname + search + hash) and sets a timeout (timeout ms) that resolves it as well. An effect on usePathname calls and clears the stored resolve, so the transition starts once the new page is on screen. When transition.finished settles, either way, remove the attribute.
- The <style> it renders, every rule behind :root[data-page-transition]: ::view-transition-group(root) animation-duration 250ms; ::view-transition-old(root) and ::view-transition-new(root) with mix-blend-mode normal, backface-visibility hidden, animation-duration 600ms, animation-timing-function cubic-bezier(0.73, 0, 0.33, 1) and animation-fill-mode both; old uses a keyframes from { opacity 1, translateY(0) } to { opacity 0, translateY(-30%) }; new uses from { opacity 0, translateY(30%) } to { opacity 1, translateY(0) } with animation-delay 500ms. Under prefers-reduced-motion: reduce set animation: none !important on all three.










