{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc-steps",
  "title": "Arc Steps",
  "description": "Pinned \"How it works\" section: a marker travels along a half circle from step to step on scroll, the arc draws in and the section flips to each step's solid color.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/arc-steps/arc-steps.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\"\nimport gsap from \"gsap\"\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\"\nimport { useGSAP } from \"@gsap/react\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\ngsap.registerPlugin(useGSAP, ScrollTrigger)\n\nexport interface ArcStep {\n  title: string\n  description: string\n  /** Solid background color of the whole section while this step is active. */\n  color: string\n  /** Text color on this step. Defaults to black or white, whichever contrasts best with `color`. */\n  foreground?: string\n  icon?: ReactNode\n}\n\n/** Solid, tasteful step colors with matching ink. */\nexport const ARC_STEPS_PALETTE = [\n  { color: \"#0a0a0a\", foreground: \"#ededed\" },\n  { color: \"#ff4d12\", foreground: \"#0a0a0a\" },\n  { color: \"#f5f0e8\", foreground: \"#0a0a0a\" },\n  { color: \"#1f3a2e\", foreground: \"#f5f0e8\" },\n  { color: \"#2b2bd9\", foreground: \"#ffffff\" },\n] as const\n\nexport type ArcStepsPosition = \"bottom\" | \"top\" | \"left\" | \"right\"\n\nexport interface ArcStepsProps {\n  /** Steps in order: { title, description, color, foreground?, icon? }. */\n  steps: ArcStep[]\n  /** Edge the half circle hugs. Bottom reads like a horizon, left/right put the content beside the arc. Default: \"bottom\" */\n  position?: ArcStepsPosition\n  /** Text alignment of the step content. Default: \"center\" */\n  align?: \"start\" | \"center\" | \"end\"\n  /** Arc radius as a % of the section's shorter side (clamped so it always fits). Default: 70 */\n  arcSize?: number\n  /** Arc stroke width in px. Default: 1.5 */\n  strokeWidth?: number\n  /** Diameter of the travelling marker in px. Default: 16 */\n  markerSize?: number\n  /** Show step numbers next to each point on the arc. Default: true */\n  showNumbers?: boolean\n  /** Snap the scroll to the nearest step when scrolling stops. Default: false */\n  snap?: boolean\n  /** Section heights of scroll reserved per step. Default: 1 */\n  scrollLength?: number\n  /** Duration of the background color change in seconds. Default: 0.8 */\n  colorTransition?: number\n  /** Small heading shown in the corner. Default: \"How it works\" */\n  title?: string\n  /** Scroll container to track instead of the window. Default: window */\n  scroller?: HTMLElement | null\n  /** Called with the step index whenever the active step changes. */\n  onStepChange?: (index: number) => void\n  /** Height of the pinned section (any CSS length). Default: \"100vh\" */\n  height?: string\n  /** Additional classes for the section. */\n  className?: string\n}\n\nconst GEOMETRY: Record<\n  ArcStepsPosition,\n  { a0: number; da: number; viewBox: string; origin: { left: string; top: string } }\n> = {\n  bottom: { a0: 180, da: 180, viewBox: \"-100 -100 200 100\", origin: { left: \"50%\", top: \"100%\" } },\n  top: { a0: 180, da: -180, viewBox: \"-100 0 200 100\", origin: { left: \"50%\", top: \"0%\" } },\n  left: { a0: -90, da: 180, viewBox: \"0 -100 100 200\", origin: { left: \"0%\", top: \"50%\" } },\n  right: { a0: -90, da: -180, viewBox: \"-100 -100 100 200\", origin: { left: \"100%\", top: \"50%\" } },\n}\n\nconst round = (v: number) => Math.round(v * 1000) / 1000\n\nfunction pointAt(deg: number) {\n  const a = (deg * Math.PI) / 180\n  return { x: round(100 * Math.cos(a)), y: round(100 * Math.sin(a)) }\n}\n\n/** Black or white ink, whichever reads better on a hex color. */\nfunction readableOn(color: string) {\n  const hex = color.trim().replace(/^#/, \"\")\n  const full = hex.length === 3 || hex.length === 4 ? hex.slice(0, 3).split(\"\").map((c) => c + c).join(\"\") : hex.slice(0, 6)\n  if (!/^[0-9a-f]{6}$/i.test(full)) return \"#ededed\"\n  const [r, g, b] = [0, 2, 4].map((i) => {\n    const c = parseInt(full.slice(i, i + 2), 16) / 255\n    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4\n  })\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? \"#0a0a0a\" : \"#ededed\"\n}\n\nconst ink = (step: ArcStep) => step.foreground ?? readableOn(step.color)\nconst pad = (n: number) => String(n).padStart(2, \"0\")\n\nfunction StepBody({ step, index, align }: { step: ArcStep; index: number; align: \"start\" | \"center\" | \"end\" }) {\n  return (\n    <>\n      <div\n        className={cn(\n          \"flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.16em] opacity-70\",\n          align === \"center\" && \"justify-center\",\n          align === \"end\" && \"justify-end\"\n        )}\n      >\n        {step.icon && <span className=\"inline-flex size-4 items-center justify-center [&_svg]:size-4\">{step.icon}</span>}\n        <span>Step {pad(index + 1)}</span>\n      </div>\n      <h3 className=\"mt-3 font-[family-name:var(--font-display)] text-[clamp(1.875rem,7.5cqmin,4.75rem)] font-semibold leading-[0.95] tracking-[-0.02em]\">\n        {step.title}\n      </h3>\n      <p\n        className={cn(\n          \"mt-3 max-w-[40ch] text-[clamp(0.875rem,2.2cqmin,1.125rem)] leading-relaxed opacity-75\",\n          align === \"center\" && \"mx-auto\",\n          align === \"end\" && \"ml-auto\"\n        )}\n      >\n        {step.description}\n      </p>\n    </>\n  )\n}\n\n/**\n * Pinned \"How it works\" section. A marker travels along a half circle from step\n * to step as you scroll, the passed arc draws in, and the whole section flips to\n * each step's solid color.\n */\nexport function ArcSteps({\n  steps,\n  position = \"bottom\",\n  align = \"center\",\n  arcSize = 70,\n  strokeWidth = 1.5,\n  markerSize = 16,\n  showNumbers = true,\n  snap = false,\n  scrollLength = 1,\n  colorTransition = 0.8,\n  title = \"How it works\",\n  scroller,\n  onStepChange,\n  height = \"100vh\",\n  className,\n}: ArcStepsProps) {\n  const sectionRef = useRef<HTMLElement>(null)\n  const armRef = useRef<HTMLDivElement>(null)\n  const fillRef = useRef<SVGPathElement>(null)\n  const activeRef = useRef(0)\n  const prevRef = useRef(0)\n  const onStepChangeRef = useRef(onStepChange)\n  const [active, setActive] = useState(0)\n  const reduced = useReducedMotion()\n  const n = steps.length\n\n  useEffect(() => {\n    onStepChangeRef.current = onStepChange\n  }, [onStepChange])\n\n  const geo = GEOMETRY[position]\n\n  // The fill path can't use non-scaling-stroke: browsers then measure dashes in\n  // screen pixels and the 0..1 pathLength pattern repeats along the arc. Instead,\n  // keep its width in arc units (radius 100) and rescale it to match on resize.\n  useEffect(() => {\n    const fill = fillRef.current\n    const box = fill?.ownerSVGElement?.parentElement\n    if (!fill || !box) return\n    const sync = () => {\n      const r = Math.max(box.clientWidth, box.clientHeight) / 2\n      if (r > 0) fill.setAttribute(\"stroke-width\", String((strokeWidth * 100) / r))\n    }\n    sync()\n    const ro = new ResizeObserver(sync)\n    ro.observe(box)\n    return () => ro.disconnect()\n  }, [strokeWidth, position])\n  const angleAt = (i: number) => geo.a0 + (n > 1 ? i / (n - 1) : 0) * geo.da\n  const start = pointAt(geo.a0)\n  const end = pointAt(geo.a0 + geo.da)\n  const arcPath = `M ${start.x} ${start.y} A 100 100 0 0 ${geo.da > 0 ? 1 : 0} ${end.x} ${end.y}`\n  const horizontal = position === \"bottom\" || position === \"top\"\n\n  // Scroll-linked pin: marker rotation + arc draw-in, with a short dwell on every point\n  useGSAP(\n    () => {\n      const section = sectionRef.current\n      const arm = armRef.current\n      const fill = fillRef.current\n      if (reduced || !section || !arm || !fill || n === 0) return\n\n      const MOVE = 0.6\n      gsap.set(arm, { rotation: angleAt(0) })\n      // Tween the SVG attribute: CSSPlugin rounds px values, which would snap 0.33 to 0\n      gsap.set(fill, { attr: { \"stroke-dashoffset\": 1 } })\n\n      const tl = gsap.timeline({\n        defaults: { ease: \"power4.inOut\", duration: MOVE },\n        scrollTrigger: {\n          trigger: section,\n          scroller: scroller ?? undefined,\n          start: \"top top\",\n          end: () => `+=${section.offsetHeight * scrollLength * n}`,\n          pin: true,\n          scrub: 0.6,\n          invalidateOnRefresh: true,\n          anticipatePin: 1,\n          snap:\n            snap && n > 1\n              ? { snapTo: \"labelsDirectional\", duration: { min: 0.2, max: 0.7 }, delay: 0.05, ease: \"power4.inOut\" }\n              : undefined,\n        },\n        onUpdate: () => {\n          const next = Math.min(n - 1, Math.max(0, Math.floor(tl.time() + 1e-4)))\n          if (next === activeRef.current) return\n          activeRef.current = next\n          setActive(next)\n          onStepChangeRef.current?.(next)\n        },\n      })\n\n      // Each step owns one unit of the timeline; moves are centered on the boundaries\n      for (let i = 0; i < n; i++) {\n        tl.addLabel(`step-${i}`, i + 0.5)\n        if (i === 0) continue\n        tl.to(arm, { rotation: angleAt(i) }, i - MOVE / 2)\n        tl.to(fill, { attr: { \"stroke-dashoffset\": 1 - i / (n - 1) } }, i - MOVE / 2)\n      }\n      tl.set({}, {}, n)\n\n      const refreshTimer = window.setTimeout(() => ScrollTrigger.refresh(), 100)\n      return () => window.clearTimeout(refreshTimer)\n    },\n    {\n      scope: sectionRef,\n      dependencies: [n, position, scrollLength, snap, scroller, reduced],\n      revertOnUpdate: true,\n    }\n  )\n\n  // A pin adds spacing that moves everything below it. Re-sort and refresh all\n  // triggers after this section (re)builds, so later pinned sections recompute\n  // their start instead of pinning too early and overlapping this one.\n  useEffect(() => {\n    const id = requestAnimationFrame(() => {\n      ScrollTrigger.sort()\n      ScrollTrigger.refresh()\n    })\n    return () => cancelAnimationFrame(id)\n  }, [n, position, scrollLength, snap, scroller, reduced])\n\n  // Solid background flip + content crossfade on every step change\n  useGSAP(\n    () => {\n      const section = sectionRef.current\n      const step = steps[active]\n      if (reduced || !section || !step) return\n\n      gsap.to(section, {\n        backgroundColor: step.color,\n        color: ink(step),\n        duration: colorTransition,\n        ease: \"power4.inOut\",\n        overwrite: \"auto\",\n      })\n\n      const dir = active >= prevRef.current ? 1 : -1\n      const changed = active !== prevRef.current\n      prevRef.current = active\n      if (!changed) return\n\n      gsap.utils.toArray<HTMLElement>(\"[data-arc-content]\", section).forEach((el, i) => {\n        if (i === active) {\n          gsap.fromTo(\n            el,\n            { autoAlpha: 0, y: 28 * dir },\n            { autoAlpha: 1, y: 0, duration: 0.9, delay: 0.12, ease: \"expo.out\", overwrite: true }\n          )\n        } else if (el.style.visibility !== \"hidden\") {\n          gsap.to(el, { autoAlpha: 0, y: -20 * dir, duration: 0.35, ease: \"power2.in\", overwrite: true })\n        }\n      })\n    },\n    { scope: sectionRef, dependencies: [active, steps, colorTransition, reduced] }\n  )\n\n  if (n === 0) return null\n\n  if (reduced) {\n    return (\n      <section aria-label={title} className={cn(\"w-full [container-type:inline-size]\", className)}>\n        {steps.map((step, i) => (\n          <div\n            key={i}\n            className=\"px-[clamp(1.25rem,6cqw,4rem)] py-[clamp(3rem,10cqw,6rem)]\"\n            style={{ backgroundColor: step.color, color: ink(step) }}\n          >\n            {i === 0 && title && <p className=\"mb-10 text-sm font-medium opacity-70\">{title}</p>}\n            <div className={cn(\"mx-auto max-w-3xl\", align === \"center\" && \"text-center\", align === \"end\" && \"text-right\")}>\n              <StepBody step={step} index={i} align={align} />\n            </div>\n          </div>\n        ))}\n      </section>\n    )\n  }\n\n  const first = steps[0]\n  const edge = \"clamp(20px, 7cqmin, 64px)\"\n  const gap = \"clamp(24px, 6cqw, 80px)\"\n  const rawRadius = horizontal ? `min(${arcSize}cqmin, calc(50cqw - var(--edge) - 28px))` : `min(${arcSize}cqmin, calc(50cqh - var(--edge) - 28px), 42cqw)`\n\n  const arcBox: CSSProperties = {\n    bottom: { left: \"calc(50% - var(--r))\", bottom: \"var(--edge)\", width: \"calc(2 * var(--r))\", height: \"var(--r)\" },\n    top: { left: \"calc(50% - var(--r))\", top: \"var(--edge)\", width: \"calc(2 * var(--r))\", height: \"var(--r)\" },\n    left: { top: \"calc(50% - var(--r))\", left: \"var(--edge)\", width: \"var(--r)\", height: \"calc(2 * var(--r))\" },\n    right: { top: \"calc(50% - var(--r))\", right: \"var(--edge)\", width: \"var(--r)\", height: \"calc(2 * var(--r))\" },\n  }[position]\n\n  const contentWidth = \"min(calc(var(--r) * 1.4), 88cqw)\"\n  const contentBox: CSSProperties = {\n    bottom: { left: `calc(50% - ${contentWidth} / 2)`, width: contentWidth, bottom: \"calc(var(--edge) + var(--r) * 0.16)\" },\n    top: { left: `calc(50% - ${contentWidth} / 2)`, width: contentWidth, top: \"calc(var(--edge) + var(--r) * 0.16)\" },\n    left: { left: `calc(var(--edge) + var(--r) + var(--gap))`, right: \"var(--edge)\", top: 0, bottom: 0 },\n    right: { right: `calc(var(--edge) + var(--r) + var(--gap))`, left: \"var(--edge)\", top: 0, bottom: 0 },\n  }[position]\n\n  const headerBox: CSSProperties = {\n    bottom: { top: \"var(--edge)\", left: \"var(--edge)\", right: \"var(--edge)\" },\n    top: { bottom: \"var(--edge)\", left: \"var(--edge)\", right: \"var(--edge)\" },\n    left: { top: \"var(--edge)\", left: \"calc(var(--edge) + var(--r) + var(--gap))\", right: \"var(--edge)\" },\n    right: { top: \"var(--edge)\", left: \"var(--edge)\", right: \"calc(var(--edge) + var(--r) + var(--gap))\" },\n  }[position]\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label={title}\n      className={cn(\"relative w-full overflow-hidden\", className)}\n      style={{ height, backgroundColor: first.color, color: ink(first) }}\n    >\n      <div\n        className=\"absolute inset-0 [container-type:size]\"\n        style={{ \"--r\": rawRadius, \"--edge\": edge, \"--gap\": gap } as CSSProperties}\n      >\n        {/* Header */}\n        <div className=\"absolute z-10 flex items-center justify-between gap-4 text-sm\" style={headerBox}>\n          <p className=\"font-medium opacity-80\">{title}</p>\n          <p className=\"font-mono text-xs tabular-nums opacity-60\">\n            {pad(active + 1)} / {pad(n)}\n          </p>\n        </div>\n\n        {/* Arc */}\n        <div aria-hidden className=\"pointer-events-none absolute\" style={arcBox}>\n          <svg viewBox={geo.viewBox} className=\"absolute inset-0 size-full overflow-visible\" fill=\"none\">\n            <path\n              d={arcPath}\n              stroke=\"currentColor\"\n              strokeOpacity={0.22}\n              strokeWidth={strokeWidth}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <path\n              ref={fillRef}\n              d={arcPath}\n              pathLength={1}\n              stroke=\"currentColor\"\n              strokeWidth={strokeWidth}\n              strokeLinecap=\"round\"\n              strokeDasharray=\"1 1\"\n              strokeDashoffset={1}\n            />\n          </svg>\n\n          {steps.map((step, i) => {\n            const deg = angleAt(i)\n            const passed = i <= active\n            const dot = Math.max(6, Math.round(markerSize * 0.5))\n            return (\n              <div\n                key={i}\n                className=\"absolute size-0\"\n                style={{ ...geo.origin, transform: `rotate(${deg}deg)` }}\n              >\n                <span\n                  className=\"absolute rounded-full border border-current\"\n                  style={{ left: \"var(--r)\", top: 0, width: dot, height: dot, margin: -dot / 2, backgroundColor: \"transparent\" }}\n                >\n                  <span\n                    className=\"absolute inset-0 rounded-full bg-current transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]\"\n                    style={{ transform: `scale(${passed ? 1 : 0})` }}\n                  />\n                </span>\n                {showNumbers && (\n                  <div\n                    className=\"absolute size-0\"\n                    style={{ left: `calc(var(--r) + ${Math.round(markerSize / 2 + 18)}px)`, top: 0, transform: `rotate(${-deg}deg)` }}\n                  >\n                    <span\n                      className={cn(\n                        \"absolute -translate-x-1/2 -translate-y-1/2 font-mono text-[11px] tabular-nums transition-opacity duration-500\",\n                        i === active ? \"opacity-100\" : \"opacity-45\"\n                      )}\n                    >\n                      {pad(i + 1)}\n                    </span>\n                  </div>\n                )}\n              </div>\n            )\n          })}\n\n          {/* Marker: an arm rotating around the circle's center keeps it exactly on the curve */}\n          <div\n            ref={armRef}\n            className=\"absolute size-0 will-change-transform\"\n            style={{ ...geo.origin, transform: `rotate(${angleAt(0)}deg)` }}\n          >\n            <span\n              className=\"absolute rounded-full bg-current\"\n              style={{\n                left: \"var(--r)\",\n                top: 0,\n                width: markerSize,\n                height: markerSize,\n                margin: -markerSize / 2,\n                boxShadow: `0 0 0 ${Math.round(markerSize / 2.5)}px color-mix(in srgb, currentColor 16%, transparent)`,\n              }}\n            />\n          </div>\n        </div>\n\n        {/* Content */}\n        <div\n          aria-live=\"polite\"\n          className={cn(\n            \"absolute z-10 grid\",\n            !horizontal && \"content-center\",\n            align === \"start\" && \"text-left\",\n            align === \"center\" && \"text-center\",\n            align === \"end\" && \"text-right\"\n          )}\n          style={contentBox}\n        >\n          {steps.map((step, i) => (\n            <div\n              key={i}\n              data-arc-content\n              className={cn(\"col-start-1 row-start-1\", position === \"bottom\" ? \"self-end\" : position === \"top\" ? \"self-start\" : \"self-center\")}\n              style={i === 0 ? undefined : { opacity: 0, visibility: \"hidden\" }}\n            >\n              <StepBody step={step} index={i} align={align} />\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/arc-steps",
  "type": "registry:component"
}