"use client";
import * as React from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
export type PageTransitionPreset = "fade" | "slide" | "scale" | "wipe";
export type PageTransitionEngine = "view-transition" | "motion";
export interface PageTransitionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "title"> {
/** Identifies the current page. Changing it plays the transition. */
pageKey: string | number;
/** The current page. */
children: React.ReactNode;
preset?: PageTransitionPreset;
/** Navigation direction: slide and wipe move the other way for "back". */
direction?: "forward" | "back" | "none";
/** Transition length in ms. */
duration?: number;
/** Use the View Transitions API when the browser has it (Motion is the fallback). */
viewTransitions?: boolean;
/** Move focus to the new page's heading after navigation. */
manageFocus?: boolean;
/** Announce the new page title in a polite live region. */
announce?: boolean;
/** Title to announce; defaults to the text of the focus target. */
title?: string;
/** Selector for the element that receives focus (and provides the title) on the new page. */
focusTarget?: string;
onTransitionComplete?: (engine: PageTransitionEngine) => void;
}
const EASE = [0.22, 1, 0.36, 1] as const;
const EASE_CSS = "cubic-bezier(0.22, 1, 0.36, 1)";
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const noop = () => () => {};
/** Feature detection after hydration (the server always renders the Motion path). */
function useViewTransitionSupport() {
return React.useSyncExternalStore(noop, () => typeof document.startViewTransition === "function", () => false);
}
/** Motion fallback variants; `d` is 1 forward, -1 back, 0 none. */
function motionVariants(preset: PageTransitionPreset, reduce: boolean): Variants {
if (reduce || preset === "fade") return { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } };
if (preset === "scale") return { enter: { opacity: 0, scale: 0.97 }, center: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 1.02 } };
if (preset === "wipe")
return {
enter: (d: number) => ({ clipPath: d < 0 ? "inset(0% 0% 0% 100%)" : "inset(0% 100% 0% 0%)", zIndex: 1 }),
center: { clipPath: "inset(0% 0% 0% 0%)", zIndex: 1 },
// The old page is cut away along the same edge, so the two never overlap.
exit: (d: number) => ({ clipPath: d < 0 ? "inset(0% 100% 0% 0%)" : "inset(0% 0% 0% 100%)", zIndex: 0 }),
};
return {
enter: (d: number) => ({ opacity: 0, x: d * 40 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: d * -40 }),
};
}
/** View Transition CSS, scoped to this instance's view-transition-name. */
function transitionCss(name: string, preset: PageTransitionPreset, d: number, ms: number, reduce: boolean) {
const p = reduce ? "fade" : preset;
const dur = reduce ? Math.min(ms, 180) : ms;
const k = (s: string) => `${name}-${s}`;
const frames: Record<PageTransitionPreset, [string, string, string]> = {
fade: ["", "", ""],
slide: [
`@keyframes ${k("out")} { to { opacity: 0; transform: translateX(${-d * 6}%); } }`,
`@keyframes ${k("in")} { from { opacity: 0; transform: translateX(${d * 6}%); } }`,
`${k("out")} ${dur}ms ${EASE_CSS} both`,
],
scale: [
`@keyframes ${k("out")} { to { opacity: 0; transform: scale(1.02); } }`,
`@keyframes ${k("in")} { from { opacity: 0; transform: scale(0.97); } }`,
`${k("out")} ${dur}ms ${EASE_CSS} both`,
],
wipe: [
// The old page is cut away along the same moving edge, so the two never overlap.
`@keyframes ${k("out")} { from { clip-path: inset(0 0 0 0); } to { clip-path: ${d < 0 ? "inset(0 100% 0 0)" : "inset(0 0 0 100%)"}; } }`,
`@keyframes ${k("in")} { from { clip-path: ${d < 0 ? "inset(0 0 0 100%)" : "inset(0 100% 0 0)"}; } to { clip-path: inset(0 0 0 0); } }`,
`${k("out")} ${dur}ms ${EASE_CSS} both`,
],
};
const [outKf, inKf, oldAnim] = frames[p];
const fade = `@keyframes ${k("fade-out")} { to { opacity: 0; } } @keyframes ${k("fade-in")} { from { opacity: 0; } }`;
const oldRule = p === "fade" ? `${k("fade-out")} ${dur}ms ease both` : oldAnim;
const newRule = p === "fade" ? `${k("fade-in")} ${dur}ms ease both` : `${k("in")} ${dur}ms ${EASE_CSS} both`;
return `${fade} ${outKf} ${inKf}
::view-transition-group(${name}) { animation-duration: ${dur}ms; animation-timing-function: ${EASE_CSS}; }
::view-transition-old(${name}) { animation: ${oldRule}; }
::view-transition-new(${name}) { animation: ${newRule}; }`;
}
/**
* Animates between pages keyed by `pageKey`. Where the browser supports it, it uses the View
* Transitions API (document.startViewTransition) with styles scoped to this element's
* view-transition-name; elsewhere it falls back to Motion's AnimatePresence. Presets: fade, slide (by
* direction), scale and wipe. After navigation it focuses the new page's heading and announces its
* title, like a good SPA router. Reduced motion becomes a short fade.
*/
export function PageTransition({
pageKey,
children,
preset = "slide",
direction = "forward",
duration = 420,
viewTransitions = true,
manageFocus = true,
announce = true,
title,
focusTarget = "h1, h2, [data-page-title]",
onTransitionComplete,
className = "",
style,
...props
}: PageTransitionProps) {
const uid = React.useId();
const name = `pt-${uid.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const reduceMotion = usePrefersReducedMotion();
const supported = useViewTransitionSupport();
const engine: PageTransitionEngine = viewTransitions && supported ? "view-transition" : "motion";
const d = direction === "back" ? -1 : direction === "none" ? 0 : 1;
// What is on screen. In View Transition mode the old page stays here until the browser has captured it.
const [shown, setShown] = React.useState<{ key: string | number; node: React.ReactNode }>({ key: pageKey, node: children });
// Motion mode: a counter keys the animated wrapper, so switching engines never replays a transition.
const [nav, setNav] = React.useState({ key: pageKey, count: 0, dir: d });
if (nav.key !== pageKey) setNav({ key: pageKey, count: engine === "motion" ? nav.count + 1 : nav.count, dir: d });
if (engine === "motion" && (shown.key !== pageKey || shown.node !== children)) setShown({ key: pageKey, node: children });
if (engine === "view-transition" && shown.key === pageKey && shown.node !== children) setShown({ key: pageKey, node: children });
const displayKey = shown.key;
const displayNode = shown.key === pageKey ? children : shown.node;
const rootRef = React.useRef<HTMLDivElement>(null);
const liveRef = React.useRef<HTMLParagraphElement>(null);
const latest = React.useRef({ pageKey, children, preset, d, duration, reduceMotion, onTransitionComplete });
const resolveCommit = React.useRef<(() => void) | null>(null);
const running = React.useRef<ViewTransition | null>(null);
const handledKey = React.useRef(displayKey);
React.useLayoutEffect(() => {
latest.current = { pageKey, children, preset, d, duration, reduceMotion, onTransitionComplete };
});
// View Transition: pageKey moved ahead of what's shown → capture the old page, then swap.
React.useLayoutEffect(() => {
if (engine !== "view-transition" || shown.key === pageKey) return;
const root = rootRef.current;
if (!root) return;
const L = latest.current;
running.current?.skipTransition();
const styleEl = document.createElement("style");
styleEl.textContent = transitionCss(name, L.preset, L.d, L.duration, L.reduceMotion);
document.head.appendChild(styleEl);
root.style.setProperty("view-transition-name", name);
let fallback = 0;
const vt = document.startViewTransition(
() =>
new Promise<void>((resolve) => {
resolveCommit.current = resolve;
// Never hold the page frozen if React doesn't commit for some reason.
fallback = window.setTimeout(resolve, 1000);
setShown({ key: latest.current.pageKey, node: latest.current.children });
}),
);
running.current = vt;
vt.ready.catch(() => {});
vt.updateCallbackDone.catch(() => {});
void vt.finished
.catch(() => {})
.finally(() => {
window.clearTimeout(fallback);
styleEl.remove();
if (running.current !== vt) return;
running.current = null;
root.style.removeProperty("view-transition-name");
latest.current.onTransitionComplete?.("view-transition");
});
}, [engine, pageKey, shown.key, name]);
// A new page is committed: release the view transition, move focus and announce.
React.useLayoutEffect(() => {
resolveCommit.current?.();
resolveCommit.current = null;
if (displayKey === handledKey.current) return;
handledKey.current = displayKey;
const root = rootRef.current;
if (!root) return;
const page = root.querySelector<HTMLElement>(`[data-pt-key="${CSS.escape(String(displayKey))}"]`);
const target = page?.querySelector<HTMLElement>(focusTarget) ?? null;
if (manageFocus && target) {
if (!target.hasAttribute("tabindex") && target.tabIndex < 0) target.setAttribute("tabindex", "-1");
target.focus({ preventScroll: true });
}
if (announce && liveRef.current) {
const text = title ?? target?.textContent?.trim() ?? "";
// Clear first so the same title twice is still announced.
liveRef.current.textContent = "";
requestAnimationFrame(() => {
if (liveRef.current) liveRef.current.textContent = text ? `${text}, page loaded` : "Page loaded";
});
}
}, [displayKey, manageFocus, announce, title, focusTarget]);
React.useEffect(
() => () => {
running.current?.skipTransition();
resolveCommit.current?.();
},
[],
);
const variants = motionVariants(preset, reduceMotion);
const seconds = (reduceMotion ? Math.min(duration, 180) : duration) / 1000;
return (
<div ref={rootRef} className={`relative ${className}`} style={style} {...props}>
<AnimatePresence initial={false} mode="popLayout" custom={nav.dir} onExitComplete={() => engine === "motion" && onTransitionComplete?.("motion")}>
<motion.div
key={nav.count}
custom={nav.dir}
variants={engine === "motion" ? variants : undefined}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: seconds, ease: reduceMotion || preset === "fade" ? "easeOut" : EASE }}
className="w-full"
>
<div key={String(displayKey)} data-pt-key={String(displayKey)}>
{displayNode}
</div>
</motion.div>
</AnimatePresence>
<p ref={liveRef} className="sr-only" aria-live="polite" aria-atomic="true" />
</div>
);
}