{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "components-page-transition-react-router",
  "type": "registry:ui",
  "title": "Page Transition (React Router)",
  "description": "Animated transitions between the pages of a React Router app, built on view transitions.",
  "dependencies": [
    "@animate-ui/react",
    "motion",
    "react-router"
  ],
  "files": [
    {
      "path": "registry/components/page-transition-react-router/index.tsx",
      "type": "registry:ui",
      "target": "@components/animate-ui/components/page-transition.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { useLocation } from 'react-router';\nimport type { TargetAndTransition } from 'motion/react';\nimport { AnimateView } from 'motion/react-animate-view';\n\nimport {\n  blur,\n  ease,\n  useShouldReduceMotion,\n} from '@animate-ui/react/utils/motion';\n\ntype PageTransitionEffect =\n  | 'fade'\n  | 'slide'\n  | 'push'\n  | 'zoom'\n  | 'iris'\n  | 'wipe'\n  | 'bands'\n  | 'stack'\n  | 'flow'\n  | 'chop'\n  | 'random';\n\ntype Effect = Exclude<PageTransitionEffect, 'random'>;\n\ntype Frame = {\n  width: number;\n  top: number;\n  bottom: number;\n  x: number;\n  y: number;\n};\n\ntype EffectAnimation = {\n  enter: TargetAndTransition;\n  exit: TargetAndTransition;\n};\n\nconst SLIDE = 60;\nconst BANDS = 5;\nconst BAND_STAGGER = 0.12;\nconst BAND_FRAMES = 16;\n\nconst still = (duration: number): TargetAndTransition => ({\n  transform: ['none', 'none'],\n  transition: { duration },\n});\n\nconst easeOutCubic = (t: number) => 1 - (1 - t) ** 3;\n\nfunction bandFrames(back: boolean, { top, bottom }: Frame) {\n  const span = 1 - (BANDS - 1) * BAND_STAGGER;\n  return Array.from({ length: BAND_FRAMES + 1 }, (_, frame) => {\n    const t = frame / BAND_FRAMES;\n    const points: string[] = [];\n    for (let band = 0; band < BANDS; band++) {\n      const progress = easeOutCubic(\n        Math.min(1, Math.max(0, (t - band * BAND_STAGGER) / span)),\n      );\n      const edge = back ? 100 : 0;\n      const reach = back ? 100 - progress * 100 : progress * 100;\n      const from = top + ((bottom - top) * band) / BANDS;\n      const to = top + ((bottom - top) * (band + 1)) / BANDS;\n      points.push(\n        `${edge}% ${from}px`,\n        `${reach}% ${from}px`,\n        `${reach}% ${to}px`,\n        `${edge}% ${to}px`,\n      );\n    }\n    return `polygon(${points.join(', ')})`;\n  });\n}\n\nconst CLIP =\n  ':root[data-page-transition]::view-transition-old(root){display:none}:root[data-page-transition]::view-transition-new(root){animation:none}' +\n  '::view-transition-image-pair(*.page-transition){overflow:clip}' +\n  '::view-transition-group(*.page-transition-shared){z-index:1;overflow:clip}' +\n  '::view-transition-old(*.page-transition-shared),::view-transition-new(*.page-transition-shared){height:100%;object-fit:cover}';\n\nconst SHARE = { duration: 0.45, ease: ease.resize };\n\nconst radii = new Map<string, string>();\n\n// Only a page change hides the old page: other view transitions (a theme\n// switch) keep theirs.\nlet marks = 0;\n\nfunction markRoot() {\n  const root = document.documentElement;\n  const mark = ++marks;\n  root.setAttribute('data-page-transition', '');\n  const transition = (\n    document as Document & { activeViewTransition?: ViewTransition | null }\n  ).activeViewTransition;\n  const done = transition\n    ? transition.finished\n    : new Promise((resolve) => requestAnimationFrame(resolve)).then(() =>\n        Promise.all(\n          root\n            .getAnimations({ subtree: true })\n            .filter((animation) =>\n              (\n                animation.effect as KeyframeEffect | null\n              )?.pseudoElement?.startsWith('::view-transition'),\n            )\n            .map((animation) => animation.finished),\n        ),\n      );\n  void done\n    .catch(() => {})\n    .finally(() => {\n      if (mark === marks) root.removeAttribute('data-page-transition');\n    });\n}\n\nfunction morphRadius(name: string, from: string | undefined, to: string) {\n  if (!from || from === to) return;\n  const animation = document.documentElement.animate(\n    { borderRadius: [from, to] },\n    {\n      pseudoElement: `::view-transition-group(${name})`,\n      duration: SHARE.duration * 1000,\n      easing: `cubic-bezier(${SHARE.ease.join(',')})`,\n      fill: 'both',\n    },\n  );\n  const transition = (\n    document as Document & { activeViewTransition?: ViewTransition | null }\n  ).activeViewTransition;\n  transition?.finished.finally(() => animation.cancel());\n}\n\nconst middle = ({ top, bottom }: Frame) => `50% ${(top + bottom) / 2}px`;\n\nconst EFFECTS: Record<\n  Effect,\n  (back: boolean, enter: Frame, exit: Frame) => EffectAnimation\n> = {\n  fade: () => ({\n    enter: {\n      opacity: [0, 1],\n      filter: [blur.panel, 'blur(0px)'],\n      transition: { duration: 0.3, ease: ease.out },\n    },\n    exit: {\n      opacity: [1, 0],\n      filter: ['blur(0px)', blur.panel],\n      transition: { duration: 0.15, ease: ease.leave },\n    },\n  }),\n  slide: (back) => {\n    const from = back ? -SLIDE : SLIDE;\n    return {\n      enter: {\n        opacity: [0, 1],\n        transform: [`translateX(${from}px)`, 'none'],\n        transition: { duration: 0.4, ease: ease.out },\n      },\n      exit: {\n        opacity: [1, 0],\n        transform: ['none', `translateX(${-from}px)`],\n        transition: { duration: 0.2, ease: ease.leave },\n      },\n    };\n  },\n  push: (back) => {\n    const from = back ? -100 : 100;\n    return {\n      enter: {\n        transform: [`translateX(${from}%)`, 'none'],\n        transition: { duration: 0.5, ease: ease.inOut },\n      },\n      exit: {\n        transform: ['none', `translateX(${-from}%)`],\n        transition: { duration: 0.5, ease: ease.inOut },\n      },\n    };\n  },\n  zoom: (_, enter, exit) => ({\n    enter: {\n      opacity: [0, 1],\n      transform: ['scale(0.94)', 'none'],\n      transformOrigin: [middle(enter), middle(enter)],\n      transition: { duration: 0.4, ease: ease.out },\n    },\n    exit: {\n      opacity: [1, 0],\n      transform: ['none', 'scale(1.04)'],\n      transformOrigin: [middle(exit), middle(exit)],\n      transition: { duration: 0.2, ease: ease.leave },\n    },\n  }),\n  iris: (_, { width, top, bottom, x, y }) => {\n    const radius = Math.hypot(\n      Math.max(x, width - x),\n      Math.max(y - top, bottom - y),\n    );\n    return {\n      enter: {\n        clipPath: [\n          `circle(0px at ${x}px ${y}px)`,\n          `circle(${radius}px at ${x}px ${y}px)`,\n        ],\n        transition: { duration: 0.6, ease: ease.resize },\n      },\n      exit: still(0.6),\n    };\n  },\n  wipe: (back) => ({\n    enter: {\n      clipPath: [back ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)', 'inset(0)'],\n      transition: { duration: 0.5, ease: ease.resize },\n    },\n    exit: {\n      transform: ['none', `translateX(${back ? 8 : -8}%)`],\n      transition: { duration: 0.5, ease: ease.resize },\n    },\n  }),\n  bands: (back, enter) => ({\n    enter: {\n      clipPath: bandFrames(back, enter),\n      transition: { duration: 0.7, ease: 'linear' },\n    },\n    exit: still(0.7),\n  }),\n  stack: (back, enter, exit) => {\n    const distance = enter.bottom - enter.top;\n    return {\n      enter: {\n        transform: [`translateY(${back ? -distance : distance}px)`, 'none'],\n        transition: { duration: 0.5, ease: ease.resize },\n      },\n      exit: {\n        transform: ['none', 'scale(0.94)'],\n        transformOrigin: [middle(exit), middle(exit)],\n        opacity: [1, 0.6],\n        transition: { duration: 0.5, ease: ease.resize },\n      },\n    };\n  },\n  flow: (back) => {\n    const from = back ? -30 : 30;\n    return {\n      enter: {\n        opacity: [0, 1],\n        transform: [`translateX(${from}%)`, 'none'],\n        filter: ['blur(12px)', 'blur(0px)'],\n        transition: { duration: 0.35, ease: ease.out },\n      },\n      exit: {\n        opacity: [1, 0],\n        transform: ['none', `translateX(${-from}%)`],\n        filter: ['blur(0px)', 'blur(12px)'],\n        transition: { duration: 0.25, ease: ease.leave },\n      },\n    };\n  },\n  chop: (back, { top, bottom }) => ({\n    enter: {\n      clipPath: back\n        ? [\n            `polygon(0% ${top}px, -20% ${top}px, 0% ${bottom}px, 0% ${bottom}px)`,\n            `polygon(0% ${top}px, 120% ${top}px, 100% ${bottom}px, 0% ${bottom}px)`,\n          ]\n        : [\n            `polygon(100% ${top}px, 100% ${top}px, 100% ${bottom}px, 120% ${bottom}px)`,\n            `polygon(-20% ${top}px, 100% ${top}px, 100% ${bottom}px, 0% ${bottom}px)`,\n          ],\n      transition: { duration: 0.55, ease: ease.inOut },\n    },\n    exit: {\n      transform: ['none', `translateX(${back ? 6 : -6}%)`],\n      transition: { duration: 0.55, ease: ease.inOut },\n    },\n  }),\n};\n\nfunction pickEffect(previous?: Effect): Effect {\n  const choices = (Object.keys(EFFECTS) as Effect[]).filter(\n    (name) => name !== previous,\n  );\n  return choices[Math.floor(Math.random() * choices.length)]!;\n}\n\nfunction frameOf(element: HTMLElement, point?: { x: number; y: number }) {\n  const rect = element.getBoundingClientRect();\n  const top = Math.min(Math.max(0, -rect.top), rect.height);\n  const bottom = Math.max(top, Math.min(rect.height, innerHeight - rect.top));\n  return {\n    width: rect.width,\n    top,\n    bottom,\n    x: point ? point.x - rect.left : rect.width / 2,\n    y: point ? point.y - rect.top : (top + bottom) / 2,\n  };\n}\n\nfunction backgroundOf(element: HTMLElement) {\n  for (\n    let node: HTMLElement | null = element;\n    node;\n    node = node.parentElement\n  ) {\n    const color = getComputedStyle(node).backgroundColor;\n    if (color !== 'transparent' && !/rgba\\(.*,\\s*0\\)$/.test(color))\n      return color;\n  }\n  return 'canvas';\n}\n\nfunction clipRadius(element: HTMLElement) {\n  const own = getComputedStyle(element).borderRadius;\n  if (own !== '0px') return own;\n  let parent = element.parentElement;\n  while (parent && getComputedStyle(parent).overflow === 'visible')\n    parent = parent.parentElement;\n  if (!parent) return '0px';\n  const style = getComputedStyle(parent);\n  const outer = parent.getBoundingClientRect();\n  const rect = element.getBoundingClientRect();\n  const top = parseFloat(style.borderTopWidth);\n  const right = parseFloat(style.borderRightWidth);\n  const bottom = parseFloat(style.borderBottomWidth);\n  const left = parseFloat(style.borderLeftWidth);\n  const touches = {\n    top: rect.top - (outer.top + top) < 1,\n    right: outer.right - right - rect.right < 1,\n    bottom: outer.bottom - bottom - rect.bottom < 1,\n    left: rect.left - (outer.left + left) < 1,\n  };\n  const corner = (radius: string, a: boolean, b: boolean, border: number) =>\n    a && b ? `${Math.max(0, parseFloat(radius) - border)}px` : '0px';\n  return [\n    corner(\n      style.borderTopLeftRadius,\n      touches.top,\n      touches.left,\n      Math.max(top, left),\n    ),\n    corner(\n      style.borderTopRightRadius,\n      touches.top,\n      touches.right,\n      Math.max(top, right),\n    ),\n    corner(\n      style.borderBottomRightRadius,\n      touches.bottom,\n      touches.right,\n      Math.max(bottom, right),\n    ),\n    corner(\n      style.borderBottomLeftRadius,\n      touches.bottom,\n      touches.left,\n      Math.max(bottom, left),\n    ),\n  ].join(' ');\n}\n\ntype PageTransitionProps = React.ComponentProps<'div'> & {\n  effect?: PageTransitionEffect;\n  pageKey?: React.Key;\n};\n\nfunction PageTransition({\n  effect = 'fade',\n  pageKey,\n  ref,\n  children,\n  ...props\n}: PageTransitionProps) {\n  const { pathname } = useLocation();\n  const key = pageKey ?? pathname;\n  const reduce = useShouldReduceMotion();\n  const localRef = React.useRef<HTMLDivElement>(null);\n  React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);\n  const styleRef = React.useRef<HTMLStyleElement>(null);\n  const pointRef = React.useRef<{ x: number; y: number } | undefined>(\n    undefined,\n  );\n  const exitRef = React.useRef<Frame | undefined>(undefined);\n  const effectRef = React.useRef<Effect>('fade');\n  const id = React.useId();\n  const className = `page-transition-${id.replace(/[^a-z0-9]/gi, '')}`;\n\n  React.useLayoutEffect(() => {\n    effectRef.current =\n      effect === 'random' ? pickEffect(effectRef.current) : effect;\n  }, [effect, key]);\n\n  const refreshStyle = () => {\n    if (!localRef.current || !styleRef.current) return;\n    styleRef.current.textContent =\n      `::view-transition-image-pair(*.${className}){border-radius:${clipRadius(localRef.current)}}` +\n      `::view-transition-old(*.${className}),::view-transition-new(*.${className}){background-color:${backgroundOf(localRef.current)}}`;\n  };\n\n  React.useLayoutEffect(refreshStyle);\n\n  React.useEffect(() => {\n    const onInput = (event: Event) => {\n      if (!localRef.current) return;\n      pointRef.current =\n        event instanceof PointerEvent\n          ? { x: event.clientX, y: event.clientY }\n          : undefined;\n      exitRef.current = frameOf(localRef.current, pointRef.current);\n    };\n    document.addEventListener('pointerdown', onInput, true);\n    document.addEventListener('keydown', onInput, true);\n    return () => {\n      document.removeEventListener('pointerdown', onInput, true);\n      document.removeEventListener('keydown', onInput, true);\n    };\n  }, []);\n\n  const animation = (types: string[]) => {\n    markRoot();\n    refreshStyle();\n    const named = types.find((type): type is Effect => type in EFFECTS);\n    const enter = frameOf(localRef.current!, pointRef.current);\n    return EFFECTS[reduce ? 'fade' : (named ?? effectRef.current)](\n      types.includes('nav-back'),\n      enter,\n      exitRef.current ?? enter,\n    );\n  };\n\n  return (\n    <AnimateView\n      key={key}\n      enter={(types) => animation(types).enter}\n      exit={(types) => animation(types).exit}\n    >\n      <div\n        ref={localRef}\n        data-slot=\"page-transition\"\n        {...props}\n        style={{\n          viewTransitionClass: `page-transition ${className}`,\n          ...props.style,\n        }}\n      >\n        <style href=\"page-transition\" precedence=\"default\">\n          {CLIP}\n        </style>\n        <style ref={styleRef} />\n        {children}\n      </div>\n    </AnimateView>\n  );\n}\n\ntype PageTransitionSharedProps = React.ComponentProps<'div'> & {\n  name: string;\n};\n\nfunction PageTransitionShared({\n  name,\n  ref,\n  ...props\n}: PageTransitionSharedProps) {\n  const localRef = React.useRef<HTMLDivElement>(null);\n  React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);\n  const fromRef = React.useRef<string | undefined>(undefined);\n\n  React.useLayoutEffect(() => {\n    if (!localRef.current) return;\n    fromRef.current = radii.get(name);\n    radii.set(name, getComputedStyle(localRef.current).borderRadius);\n  });\n\n  return (\n    <AnimateView\n      name={name}\n      transition={SHARE}\n      onAnimationStart={(_, type) => {\n        if (type === 'share' && localRef.current)\n          morphRadius(\n            name,\n            fromRef.current,\n            getComputedStyle(localRef.current).borderRadius,\n          );\n      }}\n    >\n      <div\n        ref={localRef}\n        data-slot=\"page-transition-shared\"\n        {...props}\n        style={{\n          viewTransitionClass: 'page-transition-shared',\n          ...props.style,\n        }}\n      />\n    </AnimateView>\n  );\n}\n\nexport {\n  PageTransition,\n  PageTransitionShared,\n  type PageTransitionProps,\n  type PageTransitionSharedProps,\n  type PageTransitionEffect,\n};\n"
    }
  ]
}