{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc-scroll",
  "type": "registry:ui",
  "title": "Arc Scroll",
  "description": "Cards that travel along a semicircle as you scroll, entering at the bottom right and leaving at the bottom left.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/arc-scroll.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * ARC SCROLL STORYBOARD   (driven by scroll progress, not time)\n *\n *    0%   section pins; card 0 at top center, the rest fanned\n *         down the right side of the arc\n *  0→100% wheel turns counter-clockwise, `spacing`° per card\n *         cards rise from bottom-right → peak at top center →\n *         fall away to bottom-left\n *         at the top:     scale 1, upright, highest z-index\n *         near the top:   neighbors spread apart so they never\n *                         overlap while trading places (apexGap)\n *         toward edges:   scale → ARC.edgeScale, tilted along the curve\n *         past visibleArc: fade out over ARC.fadeArc degrees\n *  100%   last card at top center; section unpins\n * ───────────────────────────────────────────────────────── */\n\nconst ARC = {\n  spacing: 18, // degrees between neighboring cards\n  visibleArc: 80, // cards stay fully visible within ±this angle of the top\n  fadeArc: 10, // degrees over which a card fades out past visibleArc\n  edgeScale: 0.85, // scale of a card at the edge of the visible arc\n  apex: 0.4, // center of the top card, as a fraction of the stage height\n  radiusRatio: 0.75, // auto radius, as a fraction of the stage width\n  maxRadius: 900, // cap for the auto radius, in px\n  apexGap: 16, // min px between neighbors as they swap at the top\n  maxSwapRatio: 0.95, // caps the spread when cards are wide for the arc\n};\n\nconst SCROLL_SPRING = { stiffness: 120, damping: 30, mass: 0.4 };\n\nconst DEG = Math.PI / 180;\n\nexport interface ArcScrollProps extends React.HTMLAttributes<HTMLElement> {\n  /** Degrees between neighboring cards along the arc. */\n  spacing?: number;\n  /** Arc radius in px. Defaults to a fraction of the stage width. */\n  radius?: number;\n  /** Pixels of scroll it takes to move one card along the arc. */\n  scrollPerCard?: number;\n  /** Rotate each card to follow the curve. */\n  tilt?: boolean;\n  /**\n   * Scroll container to track. Defaults to the window. The container must be\n   * positioned (e.g. `relative`), or scroll progress is measured from the page.\n   */\n  containerRef?: React.RefObject<HTMLElement | null>;\n  /** Classes for the pinned stage. Set its height here. */\n  stageClassName?: string;\n  /** Classes for the wrapper around every card. */\n  cardClassName?: string;\n  /**\n   * Minimum gap in px between two cards as they trade places at the top.\n   * Cards spread apart near the top to make room, then settle back to\n   * `spacing` toward the edges.\n   */\n  apexGap?: number;\n  /**\n   * Content pinned inside the stage, behind the cards: headlines, captions,\n   * progress. Position it with `absolute` classes.\n   */\n  overlay?: React.ReactNode;\n  /** Called with the index of the card nearest the top whenever it changes. */\n  onActiveChange?: (index: number) => void;\n}\n\nexport function ArcScroll({\n  spacing = ARC.spacing,\n  radius,\n  scrollPerCard = 320,\n  tilt = true,\n  containerRef,\n  stageClassName,\n  cardClassName,\n  apexGap = ARC.apexGap,\n  overlay,\n  onActiveChange,\n  className,\n  children,\n  ...props\n}: ArcScrollProps) {\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const stage = useElementSize(stageRef);\n  const reduceMotion = useReducedMotion();\n\n  const cards = React.Children.toArray(children);\n  const count = cards.length;\n  const card = useLargestCard(stageRef, count);\n\n  const { scrollYProgress } = useScroll({\n    target: sectionRef,\n    container: containerRef,\n    offset: [\"start start\", \"end end\"],\n  });\n  const smoothProgress = useSpring(scrollYProgress, SCROLL_SPRING);\n  const progress = reduceMotion ? scrollYProgress : smoothProgress;\n\n  // How far the wheel has turned, in degrees. Card i sits at i * spacing\n  // minus this, so 0° is the top and positive angles are on the right.\n  const turn = useTransform(progress, (p) => p * Math.max(count - 1, 0) * spacing);\n\n  // The card nearest the top. Read from the smoothed turn, so it matches what's\n  // on screen rather than the raw scroll position.\n  const activeRef = React.useRef(0);\n  useMotionValueEvent(turn, \"change\", (t) => {\n    if (!onActiveChange || spacing <= 0) return;\n    const index = Math.min(Math.max(Math.round(t / spacing), 0), Math.max(count - 1, 0));\n    if (index === activeRef.current) return;\n    activeRef.current = index;\n    onActiveChange(index);\n  });\n\n  const arcRadius = radius ?? Math.min(stage.width * ARC.radiusRatio, ARC.maxRadius);\n  const tilted = tilt && !reduceMotion;\n  const spread = apexSpread({ spacing, radius: arcRadius, card, gap: apexGap, tilt: tilted });\n\n  return (\n    <section ref={sectionRef} className={cn(\"relative\", className)} {...props}>\n      <div\n        ref={stageRef}\n        className={cn(\"sticky top-0 h-dvh w-full overflow-hidden\", stageClassName)}\n        // Hidden until measured, so cards don't flash stacked at the center.\n        style={{ visibility: stage.width ? undefined : \"hidden\" }}\n      >\n        {overlay}\n        {cards.map((card, index) => (\n          <ArcCard\n            key={index}\n            baseAngle={index * spacing}\n            spacing={spacing}\n            spread={spread}\n            turn={turn}\n            radius={arcRadius}\n            apexY={stage.height * ARC.apex}\n            tilt={tilted}\n            className={cardClassName}\n          >\n            {card}\n          </ArcCard>\n        ))}\n      </div>\n      {/* Scroll runway: the stage stays pinned while this scrolls past. */}\n      <div aria-hidden style={{ height: Math.max(count - 1, 0) * scrollPerCard }} />\n    </section>\n  );\n}\n\n/**\n * Two neighbors trade places (and z-index) when they're equally far from the\n * top. If they overlap at that moment, the overlap flips from one card to the\n * other in a single frame. To avoid it, cards are pushed apart near the top by\n * `spread * tanh(a / spacing)` degrees: wide enough there that the pair clears,\n * fading to a constant offset toward the edges so `spacing` holds again.\n */\nfunction apexSpread({\n  spacing,\n  radius,\n  card,\n  gap,\n  tilt,\n}: {\n  spacing: number;\n  radius: number;\n  card: { width: number; height: number };\n  gap: number;\n  tilt: boolean;\n}) {\n  if (!card.width || spacing <= 0 || radius <= 0) return 0;\n  // Tilted cards fan inward, so their bottom corners meet first.\n  const reach = tilt ? radius - card.height / 2 : radius;\n  const ratio = reach > 0 ? (card.width + gap) / (2 * reach) : 1;\n  const needed = (2 * Math.asin(Math.min(ratio, ARC.maxSwapRatio))) / DEG;\n  // At the swap each card sits at ±spacing/2, pushed out by spread * tanh(0.5).\n  return Math.max(0, (needed - spacing) / (2 * Math.tanh(0.5)));\n}\n\nfunction ArcCard({\n  baseAngle,\n  spacing,\n  spread,\n  turn,\n  radius,\n  apexY,\n  tilt,\n  className,\n  children,\n}: {\n  baseAngle: number;\n  spacing: number;\n  spread: number;\n  turn: MotionValue<number>;\n  radius: number;\n  apexY: number;\n  tilt: boolean;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const angle = useTransform(turn, (t) => {\n    const a = baseAngle - t;\n    return spread ? a + spread * Math.tanh(a / spacing) : a;\n  });\n\n  const x = useTransform(angle, (a) => radius * Math.sin(a * DEG));\n  const y = useTransform(angle, (a) => apexY + radius * (1 - Math.cos(a * DEG)));\n  const rotate = useTransform(angle, (a) => (tilt ? a : 0));\n  const scale = useTransform(angle, (a) => {\n    const t = Math.min(Math.abs(a) / ARC.visibleArc, 1);\n    return 1 - (1 - ARC.edgeScale) * t;\n  });\n  const opacity = useTransform(angle, (a) => {\n    const past = Math.abs(a) - ARC.visibleArc;\n    return past <= 0 ? 1 : Math.max(0, 1 - past / ARC.fadeArc);\n  });\n  const zIndex = useTransform(angle, (a) => Math.round(1000 - Math.abs(a) * 10));\n  const visibility = useTransform(opacity, (o) => (o === 0 ? \"hidden\" : \"visible\"));\n\n  // A zero-size anchor rides the arc; the card is centered on it, so it\n  // rotates and scales around its own center.\n  return (\n    <motion.div\n      className=\"absolute top-0 left-1/2 size-0 will-change-transform\"\n      style={{ x, y, rotate, scale, opacity, zIndex, visibility }}\n    >\n      <div\n        data-arc-card\n        className={cn(\"absolute -translate-x-1/2 -translate-y-1/2\", className)}\n      >\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nfunction useElementSize(ref: React.RefObject<HTMLElement | null>) {\n  const [size, setSize] = React.useState({ width: 0, height: 0 });\n\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n\n    const update = () => setSize({ width: el.clientWidth, height: el.clientHeight });\n    update();\n\n    const observer = new ResizeObserver(update);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n\n/** Size of the widest and tallest card, unaffected by the arc's transforms. */\nfunction useLargestCard(stageRef: React.RefObject<HTMLElement | null>, count: number) {\n  const [size, setSize] = React.useState({ width: 0, height: 0 });\n\n  React.useLayoutEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const cards = Array.from(stage.querySelectorAll<HTMLElement>(\"[data-arc-card]\"));\n\n    const update = () =>\n      setSize({\n        width: Math.max(0, ...cards.map((card) => card.offsetWidth)),\n        height: Math.max(0, ...cards.map((card) => card.offsetHeight)),\n      });\n    update();\n\n    const observer = new ResizeObserver(update);\n    cards.forEach((card) => observer.observe(card));\n    return () => observer.disconnect();\n  }, [stageRef, count]);\n\n  return size;\n}\n"
    }
  ]
}