{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"arc-picker","type":"registry:component","title":"Arc Picker","description":"Radial text picker with top, bottom, left or right curves, smooth wheel movement and live selection on each scroll step. One persistent bracket frame grows and squeezes around each label. Supports drag momentum, keyboard input, disabled choices, controlled or uncontrolled values, forms and reduced motion.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/arc-picker.tsx","type":"registry:component","target":"@components/motion/arc-picker.tsx","content":"\"use client\";\n// beui.dev/components/motion/arc-picker\n\nimport {\n  animate,\n  type AnimationPlaybackControls,\n  motion,\n  type MotionValue,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ComponentProps,\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_GLIDE, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ArcPickerOption = {\n  value: string;\n  label: string;\n  disabled?: boolean;\n};\n\nexport type ArcPickerSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport type ArcPickerProps = Omit<\n  ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> & {\n  options: readonly ArcPickerOption[];\n  value?: string;\n  defaultValue?: string;\n  /** Called for each enabled choice crossed during scrolling, dragging and momentum. */\n  onValueChange?: (value: string) => void;\n  /** The side the arc bows toward. Top and bottom scroll horizontally. */\n  side?: ArcPickerSide;\n  /** Radius in pixels. Larger radii make a gentler curve. */\n  radius?: number;\n  /** Minimum spacing along the arc. Horizontal sides also make room for label widths. */\n  itemHeight?: number;\n  visibleCount?: number;\n  disabled?: boolean;\n  name?: string;\n};\n\n// A short velocity projection lets a flick coast through several detents\n// before the shared layout spring settles it, without a long free-running loop.\nconst COAST_SECONDS = 0.18;\nconst WHEEL_SETTLE_MS = 120;\n// Reserve room for the glyphs and their small outward movement without\n// animating padding or changing any label's measured width.\nconst BRACKET_SPACE = 24;\nconst BRACKET_GAP = 4;\nconst BRACKET_EXPANSION = 6;\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\n\nfunction ArcOption({\n  option,\n  index,\n  position,\n  verticalAnchor,\n  radius,\n  itemHeight,\n  visibleCount,\n  side,\n  selected,\n  disabled,\n  setRef,\n  onSelect,\n}: {\n  option: ArcPickerOption;\n  index: number;\n  position: MotionValue<number>;\n  verticalAnchor: number | string;\n  radius: number;\n  itemHeight: number;\n  visibleCount: number;\n  side: ArcPickerSide;\n  selected: boolean;\n  disabled: boolean;\n  setRef: (element: HTMLButtonElement | null) => void;\n  onSelect: () => void;\n}) {\n  const horizontal = side === \"top\" || side === \"bottom\";\n  const transform = useTransform(position, (at) => {\n    const angle = clamp(((index - at) * itemHeight) / radius, -1.5, 1.5);\n    const bend = radius * (1 - Math.cos(angle));\n    const along = radius * Math.sin(angle);\n    const x = horizontal ? along : side === \"left\" ? bend : -bend;\n    const y = horizontal ? (side === \"top\" ? bend : -bend) : along;\n    const rotation =\n      ((side === \"left\" || side === \"bottom\" ? -angle : angle) * 180) / Math.PI;\n    const anchor = horizontal ? \"-50%\" : side === \"left\" ? \"-100%\" : \"0%\";\n    return `translate3d(${x}px, ${y}px, 0) translate(${anchor}, -50%) rotate(${rotation}deg)`;\n  });\n  const opacity = useTransform(position, (at) => {\n    const distance = Math.abs(index - at);\n    return (\n      clamp((visibleCount / 2 + 0.5 - distance) / 1.5, 0, 1) *\n      (disabled || option.disabled ? 0.35 : 1)\n    );\n  });\n  const pointerEvents = useTransform(position, (at) =>\n    Math.abs(index - at) < visibleCount / 2 ? \"auto\" : \"none\",\n  );\n\n  return (\n    <motion.button\n      ref={setRef}\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={selected}\n      disabled={disabled || option.disabled}\n      tabIndex={selected && !disabled && !option.disabled ? 0 : -1}\n      data-slot=\"arc-picker-option\"\n      data-value={option.value}\n      data-selected={selected || undefined}\n      onClick={(event) => {\n        if (!event.defaultPrevented) onSelect();\n      }}\n      className={cn(\n        \"absolute rounded-md px-2 py-1 text-2xl font-light whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-35\",\n        horizontal && \"text-center\",\n        !horizontal && (side === \"left\" ? \"text-right\" : \"text-left\"),\n        selected ? \"text-primary\" : \"text-muted-foreground\",\n      )}\n      style={{\n        top: horizontal ? (side === \"top\" ? 36 : \"calc(100% - 36px)\") : \"50%\",\n        left: horizontal ? \"50%\" : verticalAnchor,\n        transformOrigin: horizontal\n          ? \"center\"\n          : side === \"left\"\n            ? \"right center\"\n            : \"left center\",\n        maxWidth: horizontal ? \"calc(100% - 4rem)\" : \"calc(100% - 3rem)\",\n        transform,\n        opacity,\n        pointerEvents,\n      }}\n    >\n      <span\n        data-slot=\"arc-picker-label\"\n        className=\"block overflow-hidden text-ellipsis\"\n      >\n        {option.label}\n      </span>\n    </motion.button>\n  );\n}\n\nfunction ArcSelectionFrame({\n  width,\n  expansion,\n  side,\n  verticalAnchor,\n  visible,\n}: {\n  width: MotionValue<number>;\n  expansion: MotionValue<number>;\n  side: ArcPickerSide;\n  verticalAnchor: number | string;\n  visible: boolean;\n}) {\n  const horizontal = side === \"top\" || side === \"bottom\";\n  const leading = useTransform<number, string>(\n    [width, expansion],\n    ([size = 0, amount = 0]) => {\n      const extent = horizontal ? size / 2 : side === \"left\" ? size : 0;\n      const gap = BRACKET_GAP + clamp(amount, 0, 1) * BRACKET_EXPANSION;\n      return `translate3d(${-extent - gap}px, -50%, 0) scaleY(${1 - clamp(amount, 0, 1) * 0.08})`;\n    },\n  );\n  const trailing = useTransform<number, string>(\n    [width, expansion],\n    ([size = 0, amount = 0]) => {\n      const extent = horizontal ? size / 2 : side === \"right\" ? size : 0;\n      const gap = BRACKET_GAP + clamp(amount, 0, 1) * BRACKET_EXPANSION;\n      return `translate3d(${extent + gap}px, -50%, 0) scaleY(${1 - clamp(amount, 0, 1) * 0.08})`;\n    },\n  );\n  const opacity = useTransform(width, (size) => (size > 0 ? 0.85 : 0));\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      data-slot=\"arc-picker-selection-frame\"\n      className=\"pointer-events-none absolute h-0 w-0 text-2xl font-light text-primary\"\n      style={{\n        top: horizontal ? (side === \"top\" ? 36 : \"calc(100% - 36px)\") : \"50%\",\n        left: horizontal ? \"50%\" : verticalAnchor,\n        visibility: visible ? undefined : \"hidden\",\n      }}\n    >\n      <motion.span\n        data-slot=\"arc-picker-bracket\"\n        data-edge=\"leading\"\n        className=\"absolute right-0\"\n        style={{ transform: leading, opacity }}\n      >\n        [\n      </motion.span>\n      <motion.span\n        data-slot=\"arc-picker-bracket\"\n        data-edge=\"trailing\"\n        className=\"absolute left-0\"\n        style={{ transform: trailing, opacity }}\n      >\n        ]\n      </motion.span>\n    </div>\n  );\n}\n\n/** A radial single-choice picker with drag momentum and native button controls. */\nexport function ArcPicker({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  side = \"right\",\n  radius = 280,\n  itemHeight = 48,\n  visibleCount = 7,\n  disabled = false,\n  name,\n  className,\n  style,\n  onKeyDown,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  onLostPointerCapture,\n  onFocusCapture,\n  onBlurCapture,\n  onClickCapture,\n  \"aria-label\": label = \"Choose an option\",\n  \"aria-describedby\": describedBy,\n  ...props\n}: ArcPickerProps) {\n  const reducedMotion = useReducedMotion();\n  const horizontal = side === \"top\" || side === \"bottom\";\n  const instructionsId = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const buttons = useRef(new Map<string, HTMLButtonElement>());\n  const first = options.find((option) => !option.disabled)?.value;\n  const [internalValue, setInternalValue] = useState(defaultValue ?? first);\n  const controlled = value !== undefined;\n  const candidate = controlled ? value : internalValue;\n  const selectedValue = options.some(\n    (option) => option.value === candidate && !option.disabled,\n  )\n    ? candidate\n    : first;\n  // Resolve identity in the same render as changed options. A removed choice\n  // cannot reappear later and silently restore an abandoned selection.\n  if (!controlled && internalValue !== selectedValue)\n    setInternalValue(selectedValue);\n  const selectedIndex = Math.max(\n    0,\n    options.findIndex((option) => option.value === selectedValue),\n  );\n  const safeRadius = Math.max(120, Number.isFinite(radius) ? radius : 280);\n  const rowHeight = Math.max(32, Number.isFinite(itemHeight) ? itemHeight : 48);\n  const count = Math.max(\n    3,\n    Number.isFinite(visibleCount) ? Math.floor(visibleCount) : 7,\n  );\n  const [measurements, setMeasurements] = useState({ width: 0, labelWidth: 0 });\n  const labelsKey = JSON.stringify(options.map((option) => option.label));\n  const spacing = horizontal\n    ? Math.max(rowHeight, measurements.labelWidth + BRACKET_SPACE * 2)\n    : rowHeight;\n  const verticalInset = Math.max(\n    BRACKET_SPACE,\n    Math.min(\n      measurements.width * 0.42,\n      measurements.width - measurements.labelWidth - BRACKET_SPACE,\n    ),\n  );\n  const verticalAnchor = measurements.width\n    ? side === \"left\"\n      ? measurements.width - verticalInset\n      : verticalInset\n    : side === \"left\"\n      ? \"58%\"\n      : \"42%\";\n  const curveRadius = horizontal\n    ? Math.max(safeRadius, spacing * 2)\n    : safeRadius;\n  const shownCount = horizontal\n    ? Math.min(\n        count,\n        Math.max(1, (measurements.width || count * spacing) / spacing),\n      )\n    : count;\n  const horizontalHeight = Math.ceil(\n    Math.max(\n      rowHeight * 2,\n      curveRadius *\n        (1 -\n          Math.cos(\n            Math.min(1.35, (shownCount * spacing) / (2 * curveRadius)),\n          )) +\n        rowHeight +\n        24,\n    ),\n  );\n  const position = useMotionValue(selectedIndex);\n  const activity = useMotionValue(0);\n  const expansion = useSpring(activity, SPRING_GLIDE);\n  const frameTarget = useMotionValue(0);\n  const frameWidth = useSpring(frameTarget, SPRING_LAYOUT);\n  const frameMeasured = useRef(false);\n  const animation = useRef<AnimationPlaybackControls | null>(null);\n  const wheelTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wheelTarget = useRef<number | null>(null);\n  const reconcileFrame = useRef(0);\n  const request = useRef(selectedValue);\n  const tracking = useRef(false);\n  const instant = useRef(false);\n  const drag = useRef<{\n    id: number;\n    coordinate: number;\n    position: number;\n    lastCoordinate: number;\n    time: number;\n    velocity: number;\n    moved: boolean;\n  } | null>(null);\n  const suppressClick = useRef(false);\n  const focusWithin = useRef(false);\n  const optionsKey = JSON.stringify(\n    options.map((option) => [option.value, !!option.disabled]),\n  );\n  const configuration = JSON.stringify([\n    optionsKey,\n    side,\n    spacing,\n    disabled,\n    !!reducedMotion,\n  ]);\n  const previousConfiguration = useRef(configuration);\n  const latest = useRef({\n    options,\n    selectedIndex,\n    selectedValue,\n    controlled,\n    disabled,\n    reducedMotion,\n    spacing,\n    horizontal,\n    onValueChange,\n  });\n  useLayoutEffect(() => {\n    latest.current = {\n      options,\n      selectedIndex,\n      selectedValue,\n      controlled,\n      disabled,\n      reducedMotion,\n      spacing,\n      horizontal,\n      onValueChange,\n    };\n  });\n\n  const measureFrame = useCallback(() => {\n    const current = latest.current;\n    const width =\n      buttons.current.get(current.selectedValue ?? \"\")?.offsetWidth ?? 0;\n    frameTarget.set(width);\n    // The first measurement and keyboard changes are immediate. Pointer\n    // selections retarget the same two glyphs, preserving spring velocity.\n    if (!frameMeasured.current || instant.current || current.reducedMotion)\n      frameWidth.jump(width);\n    frameMeasured.current = true;\n  }, [frameTarget, frameWidth]);\n\n  useLayoutEffect(() => {\n    // Observe untransformed sizes: animated bounds would change the spacing\n    // every frame. Font loading and responsive text sizes still trigger this.\n    void optionsKey;\n    void labelsKey;\n    const element = root.current;\n    if (!element) return;\n    const measure = () => {\n      const width = element.clientWidth;\n      const labelWidth = Math.max(\n        0,\n        ...Array.from(buttons.current.values(), (button) => button.offsetWidth),\n      );\n      setMeasurements((previous) =>\n        previous.width === width && previous.labelWidth === labelWidth\n          ? previous\n          : { width, labelWidth },\n      );\n      // A font or truncated label can resize while the largest label stays\n      // the same width. Update the frame even when React needs no new measure.\n      measureFrame();\n    };\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    for (const button of buttons.current.values()) observer.observe(button);\n    measure();\n    return () => observer.disconnect();\n  }, [optionsKey, labelsKey, measureFrame]);\n\n  useLayoutEffect(() => {\n    void selectedValue;\n    void reducedMotion;\n    measureFrame();\n  }, [selectedValue, reducedMotion, measureFrame]);\n\n  const stop = useCallback(() => {\n    animation.current?.stop();\n    animation.current = null;\n    if (wheelTimer.current) clearTimeout(wheelTimer.current);\n    wheelTimer.current = null;\n    wheelTarget.current = null;\n    cancelAnimationFrame(reconcileFrame.current);\n    tracking.current = false;\n    activity.set(0);\n    if (latest.current.reducedMotion) expansion.jump(0);\n    const pointer = drag.current;\n    drag.current = null;\n    if (pointer && root.current?.hasPointerCapture(pointer.id))\n      root.current.releasePointerCapture(pointer.id);\n  }, [activity, expansion]);\n\n  const settle = useCallback(\n    (index: number, immediate = false) => {\n      animation.current?.stop();\n      const complete = () => {\n        tracking.current = false;\n        animation.current = null;\n        wheelTarget.current = null;\n        activity.set(0);\n      };\n      if (immediate || latest.current.reducedMotion) {\n        position.jump(index);\n        complete();\n      } else\n        animation.current = animate(position, index, {\n          ...SPRING_LAYOUT,\n          onComplete: complete,\n        });\n    },\n    [activity, position],\n  );\n\n  useLayoutEffect(() => {\n    const changed = previousConfiguration.current !== configuration;\n    previousConfiguration.current = configuration;\n    // An accepted live selection must not cancel the gesture or snap its\n    // floating position. External changes still own the control immediately.\n    if (changed || !tracking.current || selectedValue !== request.current) {\n      stop();\n      request.current = selectedValue;\n      settle(selectedIndex, instant.current || disabled || !!reducedMotion);\n    }\n    if (changed) {\n      suppressClick.current = false;\n    }\n    instant.current = false;\n    if (focusWithin.current && selectedValue !== undefined && !disabled) {\n      buttons.current.get(selectedValue)?.focus({ preventScroll: true });\n    }\n  }, [\n    selectedValue,\n    selectedIndex,\n    configuration,\n    disabled,\n    reducedMotion,\n    settle,\n    stop,\n  ]);\n\n  const publish = useCallback(\n    (index: number, keyboard = false) => {\n      const current = latest.current;\n      const option = current.options[index];\n      if (!option || option.disabled || current.disabled) return;\n      if (option.value === request.current) return;\n      request.current = option.value;\n      if (!current.controlled) setInternalValue(option.value);\n      current.onValueChange?.(option.value);\n      if (!current.controlled) return;\n      cancelAnimationFrame(reconcileFrame.current);\n      // Give React a full frame to commit the consumer's update before checking\n      // for rejection. A same-frame RAF can run before that commit and cancel\n      // an accepted gesture, especially when a detent is crossed inside a RAF.\n      reconcileFrame.current = requestAnimationFrame(() => {\n        reconcileFrame.current = requestAnimationFrame(() => {\n          if (\n            request.current !== option.value ||\n            latest.current.selectedValue === option.value\n          )\n            return;\n          stop();\n          request.current = latest.current.selectedValue;\n          settle(latest.current.selectedIndex, keyboard);\n          if (\n            (keyboard || focusWithin.current) &&\n            latest.current.selectedValue !== undefined\n          ) {\n            buttons.current\n              .get(latest.current.selectedValue)\n              ?.focus({ preventScroll: true });\n          }\n        });\n      });\n    },\n    [settle, stop],\n  );\n\n  const select = useCallback(\n    (index: number, keyboard = false) => {\n      const current = latest.current;\n      const option = current.options[index];\n      if (!option || option.disabled || current.disabled) return;\n      stop();\n      instant.current = keyboard;\n      if (keyboard) {\n        expansion.jump(0);\n        frameWidth.jump(frameTarget.get());\n      }\n      publish(index, keyboard);\n      settle(index, keyboard);\n      if (keyboard)\n        buttons.current.get(option.value)?.focus({ preventScroll: true });\n    },\n    [expansion, frameTarget, frameWidth, publish, settle, stop],\n  );\n\n  const nearestEnabled = useCallback((at: number) => {\n    let nearest = -1;\n    let distance = Infinity;\n    latest.current.options.forEach((option, index) => {\n      if (!option.disabled && Math.abs(index - at) < distance) {\n        nearest = index;\n        distance = Math.abs(index - at);\n      }\n    });\n    return nearest;\n  }, []);\n\n  useEffect(\n    () =>\n      position.on(\"change\", (at) => {\n        if (!tracking.current || latest.current.disabled) return;\n        const next = nearestEnabled(at);\n        if (next < 0) return;\n        const previous = latest.current.options.findIndex(\n          (option) => option.value === request.current,\n        );\n        // A large frame delta may cross several detents. Notify each enabled\n        // choice in order; never wait for the wheel-idle timer or pointer release.\n        const step = next > previous ? 1 : -1;\n        if (previous < 0 || previous === next) publish(next);\n        else\n          for (\n            let index = previous + step;\n            step > 0 ? index <= next : index >= next;\n            index += step\n          )\n            publish(index);\n      }),\n    [nearestEnabled, position, publish],\n  );\n\n  useEffect(() => {\n    const element = root.current;\n    if (!element) return;\n    const wheel = (event: WheelEvent) => {\n      const current = latest.current;\n      if (\n        current.disabled ||\n        event.ctrlKey ||\n        drag.current?.moved ||\n        !current.options.some((option) => !option.disabled)\n      )\n        return;\n      const useX = Math.abs(event.deltaX) > Math.abs(event.deltaY);\n      if (!current.horizontal && useX) return;\n      const delta =\n        (current.horizontal && useX ? event.deltaX : event.deltaY) *\n        (event.deltaMode === 1\n          ? 16\n          : event.deltaMode === 2\n            ? current.horizontal\n              ? element.clientWidth\n              : element.clientHeight\n            : 1);\n      if (!delta) return;\n      const enabled = current.options\n        .map((option, index) => ({ option, index }))\n        .filter(({ option }) => !option.disabled);\n      const first = enabled[0]?.index ?? 0;\n      const last = enabled[enabled.length - 1]?.index ?? first;\n      const from = wheelTarget.current ?? position.get();\n      // At an end, release wheel events back to the page's own scroll.\n      if ((from <= first && delta < 0) || (from >= last && delta > 0)) return;\n      event.preventDefault();\n      if (wheelTimer.current) clearTimeout(wheelTimer.current);\n      if (current.reducedMotion) {\n        const at = enabled.findIndex(\n          ({ option }) => option.value === request.current,\n        );\n        const next =\n          enabled[clamp(at + Math.sign(delta), 0, enabled.length - 1)];\n        if (next) select(next.index, true);\n      } else {\n        if (wheelTarget.current === null) {\n          animation.current?.stop();\n          animation.current = null;\n        }\n        instant.current = false;\n        tracking.current = true;\n        activity.set(1);\n        const next = clamp(from + delta / current.spacing, first, last);\n        // Wheel deltas already contain trackpad momentum. Follow them directly;\n        // useTransform coalesces visual writes to one per frame. A spring here\n        // lags behind frequent input and can feel stuck until scrolling stops.\n        wheelTarget.current = next;\n        position.set(next);\n        wheelTimer.current = setTimeout(() => {\n          wheelTimer.current = null;\n          wheelTarget.current = null;\n          settle(nearestEnabled(next));\n        }, WHEEL_SETTLE_MS);\n      }\n    };\n    element.addEventListener(\"wheel\", wheel, { passive: false });\n    return () => {\n      element.removeEventListener(\"wheel\", wheel);\n      stop();\n    };\n  }, [activity, nearestEnabled, position, select, settle, stop]);\n\n  const keyboard = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || disabled) return;\n    const enabled = options\n      .map((option, index) => ({ option, index }))\n      .filter(({ option }) => !option.disabled);\n    if (!enabled.length) return;\n    const at = Math.max(\n      0,\n      enabled.findIndex(({ option }) => option.value === request.current),\n    );\n    let next: number;\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowRight\":\n        next = at + (event.shiftKey ? 5 : 1);\n        break;\n      case \"ArrowUp\":\n      case \"ArrowLeft\":\n        next = at - (event.shiftKey ? 5 : 1);\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = enabled.length - 1;\n        break;\n      case \"PageDown\":\n        next = at + 5;\n        break;\n      case \"PageUp\":\n        next = at - 5;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    const option = enabled[clamp(next, 0, enabled.length - 1)];\n    if (option) select(option.index, true);\n  };\n\n  const release = (event: PointerEvent<HTMLDivElement>, cancelled: boolean) => {\n    const current = drag.current;\n    if (!current || current.id !== event.pointerId) return;\n    drag.current = null;\n    if (root.current?.hasPointerCapture(event.pointerId))\n      root.current.releasePointerCapture(event.pointerId);\n    if (!current.moved) return;\n    suppressClick.current = true;\n    if (cancelled) {\n      tracking.current = false;\n      activity.set(0);\n      settle(latest.current.selectedIndex);\n    } else {\n      const velocity =\n        performance.now() - current.time > 100 ? 0 : current.velocity;\n      settle(\n        nearestEnabled(\n          position.get() + (reducedMotion ? 0 : velocity * COAST_SECONDS),\n        ),\n      );\n    }\n  };\n\n  return (\n    <div\n      {...props}\n      ref={root}\n      role=\"radiogroup\"\n      aria-label={label}\n      aria-describedby={[describedBy, instructionsId].filter(Boolean).join(\" \")}\n      aria-disabled={disabled || undefined}\n      aria-orientation={horizontal ? \"horizontal\" : \"vertical\"}\n      data-slot=\"arc-picker\"\n      data-side={side}\n      className={cn(\n        \"relative w-full min-w-0 cursor-grab select-none overflow-hidden active:cursor-grabbing\",\n        disabled && \"cursor-default opacity-50\",\n        className,\n      )}\n      style={{\n        touchAction: horizontal ? \"pan-y pinch-zoom\" : \"pan-x pinch-zoom\",\n        height: horizontal ? horizontalHeight : count * rowHeight,\n        maskImage: `linear-gradient(to ${horizontal ? \"right\" : \"bottom\"}, transparent, black 12%, black 88%, transparent)`,\n        ...style,\n      }}\n      onKeyDown={keyboard}\n      onFocusCapture={(event) => {\n        focusWithin.current = true;\n        onFocusCapture?.(event);\n      }}\n      onBlurCapture={(event) => {\n        if (event.relatedTarget && !root.current?.contains(event.relatedTarget))\n          focusWithin.current = false;\n        onBlurCapture?.(event);\n      }}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (\n          event.defaultPrevented ||\n          disabled ||\n          selectedValue === undefined ||\n          event.button !== 0 ||\n          !event.isPrimary\n        )\n          return;\n        stop();\n        suppressClick.current = false;\n        const coordinate = horizontal ? event.clientX : event.clientY;\n        drag.current = {\n          id: event.pointerId,\n          coordinate,\n          position: position.get(),\n          lastCoordinate: coordinate,\n          time: performance.now(),\n          velocity: 0,\n          moved: false,\n        };\n      }}\n      onPointerMove={(event) => {\n        onPointerMove?.(event);\n        const current = drag.current;\n        if (\n          event.defaultPrevented ||\n          !current ||\n          current.id !== event.pointerId\n        )\n          return;\n        const coordinate = horizontal ? event.clientX : event.clientY;\n        const delta = coordinate - current.coordinate;\n        if (!current.moved && Math.abs(delta) < 6) return;\n        current.moved = true;\n        tracking.current = true;\n        instant.current = false;\n        activity.set(reducedMotion ? 0 : 1);\n        root.current?.setPointerCapture(event.pointerId);\n        const now = performance.now();\n        const elapsed = Math.max(8, now - current.time);\n        current.velocity =\n          current.velocity * 0.35 +\n          ((current.lastCoordinate - coordinate) / spacing / elapsed) *\n            1000 *\n            0.65;\n        current.lastCoordinate = coordinate;\n        current.time = now;\n        const next = clamp(\n          current.position - delta / spacing,\n          0,\n          Math.max(0, options.length - 1),\n        );\n        position.set(reducedMotion ? Math.round(next) : next);\n      }}\n      onPointerUp={(event) => {\n        onPointerUp?.(event);\n        release(event, false);\n      }}\n      onPointerCancel={(event) => {\n        onPointerCancel?.(event);\n        release(event, true);\n      }}\n      onLostPointerCapture={(event) => {\n        onLostPointerCapture?.(event);\n        release(event, true);\n      }}\n      onClickCapture={(event) => {\n        onClickCapture?.(event);\n        if (suppressClick.current && event.detail > 0) {\n          event.preventDefault();\n          event.stopPropagation();\n          suppressClick.current = false;\n        }\n      }}\n    >\n      <span id={instructionsId} className=\"sr-only\">\n        Drag {horizontal ? \"horizontally\" : \"vertically\"} or scroll to choose.\n        Use arrow keys to move, Home and End to jump.\n      </span>\n      {name && (\n        <input\n          type=\"hidden\"\n          name={name}\n          value={selectedValue ?? \"\"}\n          disabled={disabled}\n        />\n      )}\n      {options.map((option, index) => (\n        <ArcOption\n          key={option.value}\n          option={option}\n          index={index}\n          position={position}\n          verticalAnchor={verticalAnchor}\n          radius={curveRadius}\n          itemHeight={spacing}\n          visibleCount={shownCount}\n          side={side}\n          selected={option.value === selectedValue}\n          disabled={disabled}\n          setRef={(element) => {\n            if (element) buttons.current.set(option.value, element);\n            else buttons.current.delete(option.value);\n          }}\n          onSelect={() => select(index)}\n        />\n      ))}\n      <ArcSelectionFrame\n        width={frameWidth}\n        expansion={expansion}\n        side={side}\n        verticalAnchor={verticalAnchor}\n        visible={selectedValue !== undefined}\n      />\n      {!options.length && (\n        <span className=\"absolute inset-0 flex items-center justify-center text-sm text-muted-foreground\">\n          No options\n        </span>\n      )}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}