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