{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cylinder-carousel",
  "type": "registry:component",
  "title": "Cylinder Carousel",
  "description": "A carousel whose items line the inside of a cylinder, receding into the center and growing toward the edges. Drag, scroll or arrow-key to roll it, with a springy glide and snap. Reduced-motion drops the glide.",
  "author": "Saurabh <saurabh10102@gmail.com>",
  "dependencies": [
    "clsx",
    "motion",
    "tailwind-merge"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/motion/cylinder-carousel.tsx",
      "type": "registry:component",
      "target": "@components/motion/cylinder-carousel.tsx",
      "content": "\"use client\";\n// asharca.github.io/ui/components/motion/cylinder-carousel\n\nimport {\n  animate,\n  type AnimationPlaybackControls,\n  motion,\n  type MotionValue,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  Children,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type WheelEvent as ReactWheelEvent,\n} from \"react\";\nimport {\n  capturePointer,\n  releasePointer,\n  TOUCH_GESTURE_CLASS,\n} from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Carousel-specific: a soft spring that receives the release velocity, so a\n// flick keeps rolling freely, drifts past the snap point and eases back.\nconst GLIDE_SPRING = { stiffness: 40, damping: 20, mass: 3 };\n\n// How far a flick keeps rolling: projected items = release velocity * momentum.\nconst FLICK_MOMENTUM = 0.45;\nconst MAX_FLICK_ITEMS = 6;\n\nexport interface CylinderCarouselProps {\n  children: ReactNode;\n  /** Max item box size in px (square) at full size, i.e. at the container\n   * edge. Balls shrink below this automatically so the row keeps breathing\n   * room in narrow containers. */\n  itemSize?: number;\n  /** How many item slots span the container width. */\n  visibleItems?: number;\n  /** \"concave\" (default): inside of the cylinder — center ball smallest and\n   * dipped, growing toward the edges. \"convex\": outside of the cylinder —\n   * center ball biggest and raised, shrinking toward the edges. */\n  variant?: \"concave\" | \"convex\";\n  /** Scale of the smallest ball (center for concave, edges for convex);\n   * the biggest reaches 1. */\n  minScale?: number;\n  /** Items rolled per item-width dragged — above 1 the wall outruns the\n   * pointer, which reads as a lighter, freer roll. */\n  dragSpeed?: number;\n  /** Curve depth in px: for concave, how far the edge balls ride above the\n   * center one (valley); for convex, how far below (arch). 0 = flat line.\n   * Defaults to 35% of the item size. */\n  arc?: number;\n  /** Snap to the nearest item when the roll settles. */\n  snap?: boolean;\n  /** Roll on its own until interacted with. */\n  autoRotate?: boolean;\n  /** Auto-roll speed in items per second. */\n  autoRotateSpeed?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /** Stage height in px. Defaults to `itemSize`. */\n  height?: number;\n  className?: string;\n}\n\n// The frame edge sits at this wall angle; how far the wall curves in frame.\nconst THETA_EDGE = (72 * Math.PI) / 180;\n// Wall angle past which a ball is parked far off-stage (always hidden there).\nconst THETA_CLAMP = (95 * Math.PI) / 180;\n\n/**\n * One ball on the inside wall of the cylinder, rendered through a single\n * perspective projection: the ball sits at wall angle θ, the camera slightly\n * above the ball line, so horizontal position, size and height all share one\n * 1/(cosθ + k) depth term. Center = far wall: smallest, highest, moving\n * slowest; edges = nearest: full size, level, moving fastest, sliding off to\n * be clipped. Nothing overlaps, fades or reorders — the edge is the exit.\n */\nfunction CarouselBall({\n  scroll,\n  index,\n  count,\n  alpha,\n  k,\n  projection,\n  gap,\n  edgeOffset,\n  minScale,\n  convex,\n  arc,\n  halfWidth,\n  itemSize,\n  children,\n}: {\n  scroll: MotionValue<number>;\n  index: number;\n  count: number;\n  /** Wall angle per item step, in radians. */\n  alpha: number;\n  /** Camera distance term for the horizontal projection. */\n  k: number;\n  /** Projection strength: maps sinθ/(cosθ+k) to px so θE lands on the edge. */\n  projection: number;\n  /** Uniform slot width in px — convex spacing. */\n  gap: number;\n  /** Offset at which a ball's center sits on the container edge. */\n  edgeOffset: number;\n  minScale: number;\n  convex: boolean;\n  /** Curve depth in px between the center ball and the edge balls. */\n  arc: number;\n  halfWidth: number;\n  itemSize: number;\n  children: ReactNode;\n}) {\n  // Nearest wrapped offset so items loop around continuously.\n  const offset = useTransform(scroll, (s) => {\n    let o = index - s;\n    o -= Math.round(o / count) * count;\n    return o;\n  });\n  // Concave spacing follows the interior perspective (slow, tight center);\n  // convex pairs its big center balls with uniform spacing — the interior\n  // projection would collapse them into each other.\n  const x = useTransform(offset, (o) => {\n    if (convex) return o * gap;\n    const th = Math.max(-THETA_CLAMP, Math.min(THETA_CLAMP, o * alpha));\n    return (projection * Math.sin(th)) / (Math.cos(th) + k);\n  });\n  // Linear in wall angle, not in depth (the depth curve is near-flat around\n  // the center, which made the middle three read as equal): every step is\n  // visibly bigger than the last — growing outward (concave) or inward\n  // (convex).\n  const scale = useTransform(offset, (o) => {\n    const t = Math.min(Math.abs(o) / edgeOffset, THETA_CLAMP / THETA_EDGE);\n    return convex\n      ? 1 - (1 - minScale) * t\n      : minScale + (1 - minScale) * t;\n  });\n  // Parabola centered on the stage — valley for concave (center ball dips\n  // arc/2 below the midline, edges rise arc/2 above), arch for convex — and\n  // deliberately unclamped: a ball keeps following the same curve as it\n  // crosses the edge, so entries never pop.\n  const y = useTransform(x, (px) => {\n    const t = px / halfWidth;\n    const valley = arc * (0.5 - t * t);\n    return convex ? -valley : valley;\n  });\n  // Fully off-stage balls stop painting (matters for canvas/shader children).\n  const visibility = useTransform(x, (px) =>\n    Math.abs(px) > halfWidth + itemSize ? \"hidden\" : \"visible\",\n  );\n\n  return (\n    <motion.div\n      className=\"absolute top-1/2 left-1/2\"\n      style={{\n        x,\n        y,\n        scale,\n        visibility,\n        width: itemSize,\n        height: itemSize,\n        marginLeft: -itemSize / 2,\n        marginTop: -itemSize / 2,\n      }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport function CylinderCarousel({\n  children,\n  itemSize = 200,\n  visibleItems = 5,\n  variant = \"concave\",\n  minScale = 0.55,\n  dragSpeed = 1.5,\n  arc: arcProp,\n  snap = true,\n  autoRotate = false,\n  autoRotateSpeed = 0.4,\n  defaultIndex = 0,\n  onIndexChange,\n  height,\n  className,\n}: CylinderCarouselProps) {\n  const reduce = useReducedMotion() ?? false;\n  const items = Children.toArray(children);\n  const count = items.length;\n\n  const stageRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => {\n      setWidth(entry.contentRect.width);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  // Sized so `visibleItems` balls sit fully in frame and the next one out on\n  // each side straddles the container edge, half visible.\n  const stageWidth = width || 800;\n  const halfWidth = stageWidth / 2;\n  const edgeOffset = (visibleItems + 1) / 2;\n\n  // Fit: the resting row's diameters may take at most ~66% of the stage — the\n  // rest is air between balls plus the half-visible ball on each edge.\n  // `itemSize` only caps the result.\n  const convex = variant === \"convex\";\n  let scaleSum = 0;\n  for (let i = 0; i < visibleItems; i++) {\n    const t = Math.abs(i - (visibleItems - 1) / 2) / edgeOffset;\n    scaleSum += convex\n      ? 1 - (1 - minScale) * t\n      : minScale + (1 - minScale) * t;\n  }\n  const size = Math.min(itemSize, (stageWidth * 0.65) / scaleSum);\n\n  const gap = stageWidth / (visibleItems + 1);\n  const arc = arcProp ?? size * 0.35;\n\n  // Perspective constants. The ball one slot past the frame edge sits at wall\n  // angle THETA_EDGE with its center right on the container edge — scale 1,\n  // half of it in view; k falls out of the requested minScale (clamped so the\n  // projection stays monotonic up to THETA_CLAMP).\n  const alpha = THETA_EDGE / edgeOffset;\n  const k = Math.max(0.2, (minScale - Math.cos(THETA_EDGE)) / (1 - minScale));\n  const projection =\n    (halfWidth * (Math.cos(THETA_EDGE) + k)) / Math.sin(THETA_EDGE);\n\n  // scroll is in item units (continuous); item i sits at x = (i - scroll) * gap.\n  // Drags write it 1:1 so the wall sticks to the pointer; releases hand the\n  // pointer velocity to a soft spring so the roll glides on and settles free.\n  const scroll = useMotionValue(defaultIndex);\n  const indexRef = useRef(defaultIndex);\n  const [, setActiveIndex] = useState(defaultIndex);\n  const glideRef = useRef<AnimationPlaybackControls | null>(null);\n  const draggingRef = useRef(false);\n  const hoverRef = useRef(false);\n\n  useEffect(() => {\n    if (count === 0) return;\n    const unsub = scroll.on(\"change\", (v) => {\n      const idx = ((Math.round(v) % count) + count) % count;\n      if (idx !== indexRef.current) {\n        indexRef.current = idx;\n        setActiveIndex(idx);\n        onIndexChange?.(idx);\n      }\n    });\n    return unsub;\n  }, [scroll, count, onIndexChange]);\n\n  const stopGlide = useCallback(() => {\n    glideRef.current?.stop();\n    glideRef.current = null;\n  }, []);\n\n  // Spring toward `to`, carrying `velocity` (items/s) through so motion never\n  // steps — the roll leaves the finger at finger speed.\n  const glideTo = useCallback(\n    (to: number, velocity: number) => {\n      stopGlide();\n      if (reduce) {\n        scroll.set(to);\n        return;\n      }\n      glideRef.current = animate(scroll, to, {\n        type: \"spring\",\n        ...GLIDE_SPRING,\n        velocity,\n        restDelta: 0.001,\n        restSpeed: 0.005,\n      });\n    },\n    [scroll, stopGlide, reduce],\n  );\n\n  const settle = useCallback(\n    (velocity: number) => {\n      const projected =\n        scroll.get() +\n        Math.max(\n          -MAX_FLICK_ITEMS,\n          Math.min(MAX_FLICK_ITEMS, velocity * FLICK_MOMENTUM),\n        );\n      glideTo(snap ? Math.round(projected) : projected, velocity);\n    },\n    [scroll, snap, glideTo],\n  );\n\n  const drag = useRef({\n    startX: 0,\n    startScroll: 0,\n    lastX: 0,\n    lastT: 0,\n    prevX: 0,\n    prevT: 0,\n  });\n\n  const onPointerDown = useCallback(\n    (e: ReactPointerEvent) => {\n      e.preventDefault();\n      stopGlide();\n      draggingRef.current = true;\n      capturePointer(e.currentTarget, e.pointerId);\n      const now = performance.now();\n      drag.current = {\n        startX: e.clientX,\n        startScroll: scroll.get(),\n        lastX: e.clientX,\n        lastT: now,\n        prevX: e.clientX,\n        prevT: now,\n      };\n    },\n    [scroll, stopGlide],\n  );\n\n  const onPointerMove = useCallback(\n    (e: ReactPointerEvent) => {\n      if (!draggingRef.current) return;\n      const d = drag.current;\n      scroll.set(d.startScroll - ((e.clientX - d.startX) * dragSpeed) / gap);\n      d.prevX = d.lastX;\n      d.prevT = d.lastT;\n      d.lastX = e.clientX;\n      d.lastT = performance.now();\n    },\n    [scroll, gap, dragSpeed],\n  );\n\n  const onPointerUp = useCallback(\n    (e: ReactPointerEvent) => {\n      if (!draggingRef.current) return;\n      draggingRef.current = false;\n      releasePointer(e.currentTarget, e.pointerId);\n      const d = drag.current;\n      const dt = d.lastT - d.prevT;\n      const vpx = dt > 0 ? (d.lastX - d.prevX) / dt : 0; // px per ms\n      settle((-vpx * dragSpeed * 1000) / gap); // items per second\n    },\n    [settle, gap, dragSpeed],\n  );\n\n  const rollBy = useCallback(\n    (dir: number) => {\n      glideTo(Math.round(scroll.get()) + dir, scroll.getVelocity());\n    },\n    [scroll, glideTo],\n  );\n\n  const wheelSettleRef = useRef<number | undefined>(undefined);\n  const onWheel = useCallback(\n    (e: ReactWheelEvent) => {\n      stopGlide();\n      const delta =\n        Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;\n      scroll.set(scroll.get() + delta / gap);\n      if (wheelSettleRef.current) window.clearTimeout(wheelSettleRef.current);\n      wheelSettleRef.current = window.setTimeout(\n        () => settle(scroll.getVelocity()),\n        140,\n      );\n    },\n    [scroll, gap, settle, stopGlide],\n  );\n\n  useEffect(() => {\n    if (!autoRotate || reduce || count === 0) return;\n    let raf = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const dt = (now - last) / 1000;\n      last = now;\n      if (!draggingRef.current && !hoverRef.current && !glideRef.current) {\n        scroll.set(scroll.get() + autoRotateSpeed * dt);\n      }\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [autoRotate, autoRotateSpeed, reduce, count, scroll]);\n\n  const stageHeight = height ?? size;\n\n  return (\n      <div\n        ref={stageRef}\n        role=\"application\"\n        aria-roledescription=\"carousel\"\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable custom carousel widget\n        tabIndex={0}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowRight\") {\n            e.preventDefault();\n            rollBy(1);\n          } else if (e.key === \"ArrowLeft\") {\n            e.preventDefault();\n            rollBy(-1);\n          }\n        }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onWheel={onWheel}\n        onPointerEnter={() => {\n          hoverRef.current = true;\n        }}\n        onPointerLeave={() => {\n          hoverRef.current = false;\n        }}\n        className={cn(\n          // clip-path, not overflow: it also clips the GPU-composited balls\n          \"relative w-full touch-none outline-none [clip-path:inset(0)]\",\n          // The stage drives the roll from the press itself, so iOS must not\n          // claim the same touch for its callout or a slide drag.\n          TOUCH_GESTURE_CLASS,\n          \"cursor-grab active:cursor-grabbing\",\n          \"focus-visible:ring-2 focus-visible:ring-foreground/20\",\n          className,\n        )}\n        style={{ height: stageHeight }}\n      >\n        {items.map((item, i) => (\n          <CarouselBall\n            // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional and stable\n            key={i}\n            scroll={scroll}\n            index={i}\n            count={count}\n            alpha={alpha}\n            k={k}\n            projection={projection}\n            gap={gap}\n            edgeOffset={edgeOffset}\n            minScale={minScale}\n            convex={convex}\n            arc={arc}\n            halfWidth={halfWidth}\n            itemSize={size}\n          >\n            {item}\n          </CarouselBall>\n        ))}\n      </div>\n  );\n}\n"
    },
    {
      "path": "lib/touch.ts",
      "type": "registry:lib",
      "target": "@lib/touch.ts",
      "content": "// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\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"
    }
  ]
}
