{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc-scroll-steps",
  "type": "registry:ui",
  "title": "Arc Scroll Steps",
  "description": "A scroll-driven stepper whose numbered steps orbit an arc while the active title swaps in.",
  "files": [
    {
      "path": "src/registry/ui/arc-scroll-steps.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useMotionTemplate,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ArcStep = { title: React.ReactNode; description?: React.ReactNode };\n\nexport type ArcScrollStepsProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  steps: ArcStep[];\n  /** Pinned to the top of the stage, e.g. a logo and a close button. */\n  header?: React.ReactNode;\n  /** Pinned to the bottom of the stage, e.g. credits and a call to action. */\n  footer?: React.ReactNode;\n  /** Degrees between neighbouring steps on the arc. */\n  spacing?: number;\n  /** Any CSS color for the active step's dot. */\n  accent?: string;\n  /** Accessible name for the scroll area. */\n  label?: string;\n};\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\nexport function ArcScrollSteps({\n  steps,\n  header,\n  footer,\n  spacing = 22,\n  accent = \"#0A6EFF\",\n  label = \"Steps\",\n  className,\n  style,\n  ...props\n}: ArcScrollStepsProps) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const reduceMotion = useReducedMotion();\n  const count = steps.length;\n\n  // Scroll progress → a continuous step position (0 … count-1). A light spring gives\n  // the wheel some weight; reduced motion follows the scroll exactly.\n  const { scrollYProgress } = useScroll({ container: scrollRef });\n  const rawPosition = useTransform(scrollYProgress, [0, 1], [0, Math.max(count - 1, 0)]);\n  const springPosition = useSpring(rawPosition, { stiffness: 260, damping: 34, mass: 0.6 });\n  const position = reduceMotion ? rawPosition : springPosition;\n  const wheelRotation = useTransform(position, (value) => -value * spacing);\n  const wheelTransform = useMotionTemplate`rotate(${wheelRotation}deg)`;\n\n  // Only the rounded step drives React state, so content swaps once per step, not per frame.\n  const [active, setActive] = useState(0);\n  const [direction, setDirection] = useState(1);\n  useMotionValueEvent(rawPosition, \"change\", (value) => {\n    const next = Math.round(value);\n    if (next === active) return;\n    setDirection(next > active ? 1 : -1);\n    setActive(next);\n  });\n\n  const goTo = (index: number) => {\n    const scroller = scrollRef.current;\n    if (!scroller) return;\n    scroller.scrollTo({ top: index * scroller.clientHeight, behavior: reduceMotion ? \"auto\" : \"smooth\" });\n  };\n\n  const step = steps[active];\n  const shift = reduceMotion ? 0 : 24;\n\n  return (\n    <div\n      ref={scrollRef}\n      data-slot=\"arc-scroll-steps\"\n      role=\"region\"\n      aria-label={label}\n      tabIndex={0}\n      style={\n        {\n          \"--arc-accent\": accent,\n          // The arc scales with the component; numbers sit just outside it.\n          \"--arc-radius\": \"clamp(9rem, 42cqw, 26rem)\",\n          ...style,\n        } as React.CSSProperties\n      }\n      className={cn(\n        \"@container relative h-[34rem] w-full snap-y snap-mandatory overflow-y-auto overscroll-auto rounded-xl bg-muted/40 text-foreground outline-none [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-scrollbar]:hidden\",\n        className\n      )}\n      {...props}\n    >\n      {/* Snap point for the first step, kept off the pinned stage: while stuck, the stage's\n          own snap position would be ambiguous. */}\n      <div aria-hidden className=\"h-0 snap-start\" />\n      {/* The stage stays pinned while the spacers below provide one screen of scroll per step. */}\n      <div className=\"sticky top-0 h-full overflow-hidden\">\n        {header && <div className=\"absolute inset-x-6 top-5 z-10 sm:inset-x-8\">{header}</div>}\n\n        {/* Thin arc, centred off the left edge so only its right side shows. */}\n        <div\n          aria-hidden\n          className=\"absolute top-[38%] size-[calc(var(--arc-radius)*2)] -translate-y-1/2 rounded-full border border-foreground/15 @md:top-1/2\"\n          style={{ left: \"calc(36% - var(--arc-radius) * 2)\" }}\n        />\n\n        {/* The wheel: a zero-size point at the arc's centre that the steps orbit. */}\n        <motion.div\n          className=\"absolute top-[38%] size-0 @md:top-1/2\"\n          style={{ left: \"calc(36% - var(--arc-radius))\", transform: wheelTransform }}\n        >\n          {steps.map((item, index) => (\n            <ArcStepMarker\n              key={index}\n              index={index}\n              position={position}\n              spacing={spacing}\n              active={index === active}\n              onSelect={() => goTo(index)}\n              label={item.title}\n            />\n          ))}\n        </motion.div>\n\n        {/* Narrow containers stack the step text under the arc; wider ones set it beside the arc. */}\n        <div\n          aria-live=\"polite\"\n          className={cn(\n            \"absolute inset-x-6 @md:inset-x-auto @md:top-1/2 @md:right-10 @md:bottom-auto @md:left-[52%] @md:-translate-y-1/2\",\n            footer ? \"bottom-20\" : \"bottom-8\"\n          )}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n            <motion.div\n              key={active}\n              custom={direction}\n              variants={{\n                enter: (dir: number) => ({ opacity: 0, transform: `translateY(${dir * shift}px)`, filter: \"blur(4px)\" }),\n                center: { opacity: 1, transform: \"translateY(0px)\", filter: \"blur(0px)\" },\n                exit: (dir: number) => ({ opacity: 0, transform: `translateY(${dir * -shift}px)`, filter: \"blur(4px)\" }),\n              }}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={{ duration: 0.35, ease: EASE_OUT }}\n            >\n              <h3 className=\"text-3xl leading-none font-semibold tracking-[-0.04em] text-balance @md:text-4xl @2xl:text-6xl\">\n                {step?.title}\n              </h3>\n              {step?.description && (\n                <p className=\"mt-3 max-w-xs text-sm text-muted-foreground\">{step.description}</p>\n              )}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n\n        {footer && <div className=\"absolute inset-x-6 bottom-5 z-10 sm:inset-x-8\">{footer}</div>}\n      </div>\n\n      {Array.from({ length: Math.max(count - 1, 0) }, (_, i) => (\n        <div key={i} aria-hidden className=\"h-full snap-start\" />\n      ))}\n    </div>\n  );\n}\n\nfunction ArcStepMarker({\n  index,\n  position,\n  spacing,\n  active,\n  onSelect,\n  label,\n}: {\n  index: number;\n  position: MotionValue<number>;\n  spacing: number;\n  active: boolean;\n  onSelect: () => void;\n  label: React.ReactNode;\n}) {\n  // 0 at the active slot, 1 once a full step away: drives size and emphasis. Steps two or more\n  // away fade out entirely, so on short or narrow stages they never sit behind the step text.\n  const distance = useTransform(position, (value) => Math.min(Math.abs(value - index), 2));\n  const scale = useTransform(distance, [0, 1, 2], [1, 0.62, 0.62]);\n  const opacity = useTransform(distance, [0, 1, 2], [1, 0.28, 0]);\n  const numberTransform = useMotionTemplate`translateY(-50%) scale(${scale})`;\n\n  return (\n    // Placed on the arc by angle; the number keeps that angle, so it tilts with the curve.\n    <div\n      className=\"absolute top-0 left-0\"\n      style={{ transform: `rotate(${index * spacing}deg) translateX(var(--arc-radius))` }}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"absolute top-0 left-0 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200\",\n          active ? \"bg-[var(--arc-accent)]\" : \"bg-foreground/30\"\n        )}\n      />\n      <motion.button\n        type=\"button\"\n        onClick={onSelect}\n        aria-current={active ? \"step\" : undefined}\n        aria-label={typeof label === \"string\" ? `Step ${index + 1}: ${label}` : `Step ${index + 1}`}\n        style={{ opacity, transform: numberTransform }}\n        className=\"absolute top-0 left-5 origin-left cursor-pointer rounded-md px-1 font-mono text-4xl leading-none font-semibold tracking-tight outline-none focus-visible:ring-2 focus-visible:ring-ring @md:text-5xl @2xl:left-7 @2xl:text-6xl\"\n      >\n        {pad(index + 1)}\n      </motion.button>\n    </div>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "motion"
  ]
}
