{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cylinder-carousel",
  "type": "registry:ui",
  "title": "Cylinder Carousel",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Interactive 3D cylindrical carousel with real-time radial perspective transforms, inertia pan physics, depth blur, and active card path animations.",
  "dependencies": [
    "framer-motion",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/cylinder-carousel.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n  type TapInfo,\n} from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ */\n/* Types & Interfaces                                                 */\n/* ------------------------------------------------------------------ */\n\nexport interface CylinderCardItem {\n  id: string | number;\n  title?: string;\n  pill?: string;\n  bg?: string;\n  dark?: boolean;\n  content?:\n    | React.ReactNode\n    | ((props: { active: boolean; index: number }) => React.ReactNode);\n  Body?: (props: { active: boolean }) => React.ReactNode;\n  children?: React.ReactNode;\n}\n\nexport interface CylinderCarouselProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  /** Array of card items positioned around the 3D cylinder passed strictly via props */\n  cards?: CylinderCardItem[];\n  /** Optional alias for cards */\n  items?: CylinderCardItem[];\n  /** Card width in pixels. Default: 280 */\n  cardWidth?: number;\n  /** Card height in pixels. Default: 360 */\n  cardHeight?: number;\n  /** Spacing between adjacent cards on cylinder circumference. Default: 70 */\n  gap?: number;\n  /** CSS perspective value in pixels. Default: 1400 */\n  perspective?: number;\n  /** Initial selected card index. Default: 0 */\n  initialIndex?: number;\n  /** Spring stiffness coefficient for snap animation. Default: 120 */\n  springStiffness?: number;\n  /** Spring damping coefficient. Default: 20 */\n  damping?: number;\n  /** Spring mass coefficient. Default: 0.9 */\n  mass?: number;\n  /** Whether to render bottom chevron and dot controls. Default: true */\n  showControls?: boolean;\n  /** Whether to render the pagination dot indicators. Default: true */\n  showDots?: boolean;\n  className?: string;\n  containerClassName?: string;\n}\n\n/* ------------------------------------------------------------------ */\n/* Constants & Helpers                                                */\n/* ------------------------------------------------------------------ */\n\nexport const DEFAULT_CYLINDER_BACKGROUNDS = [\n  \"radial-gradient(circle at 50% 55%, #17a2c8 0%, #6cc9a3 38%, #d5e59a 62%, #fbd3b6 88%)\",\n  \"linear-gradient(160deg, #6d6cf5 0%, #c15cf0 45%, #f06bd0 100%)\",\n  \"linear-gradient(180deg, #f7f8fa 0%, #eef0f4 100%)\",\n  \"linear-gradient(165deg, #3ccfb4 0%, #6fd6e8 55%, #b6e7f7 100%)\",\n  \"linear-gradient(165deg, #ff9a5a 0%, #ff7a8a 55%, #f4a6d0 100%)\",\n];\n\nexport function normaliseAngle(deg: number): number {\n  return ((((deg + 180) % 360) + 360) % 360) - 180;\n}\n\nexport function CylinderPill({\n  label,\n  className,\n}: {\n  label: string;\n  className?: string;\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-full bg-white/90 px-3 py-1 text-[11px] font-medium text-neutral-800 shadow-sm\",\n        className\n      )}\n    >\n      {label}\n      <svg\n        viewBox=\"0 0 10 6\"\n        className=\"h-1.5 w-2 fill-none stroke-current\"\n        strokeWidth=\"1.5\"\n        aria-hidden=\"true\"\n      >\n        <path d=\"M1 1l4 4 4-4\" />\n      </svg>\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* CylinderCard Subcomponent                                          */\n/* ------------------------------------------------------------------ */\n\ninterface CylinderCardProps {\n  card: CylinderCardItem;\n  index: number;\n  step: number;\n  radius: number;\n  cardWidth: number;\n  cardHeight: number;\n  rotation: MotionValue<number>;\n  active: boolean;\n  onSelect: () => void;\n}\n\nfunction CylinderCard({\n  card,\n  index,\n  step,\n  radius,\n  cardWidth,\n  cardHeight,\n  rotation,\n  active,\n  onSelect,\n}: CylinderCardProps) {\n  const angle = (r: number) => normaliseAngle(index * step + r);\n\n  const transform = useTransform(\n    rotation,\n    (r) => `rotateY(${angle(r)}deg) translateZ(${radius}px)`\n  );\n  const filter = useTransform(rotation, (r) => {\n    const t = Math.min(Math.abs(angle(r)) / step, 2);\n    return `blur(${(t * 3.2).toFixed(2)}px) saturate(${(1 - t * 0.15).toFixed(2)})`;\n  });\n  const opacity = useTransform(rotation, (r) => {\n    const t = Math.min(Math.abs(angle(r)) / step, 3);\n    return Math.max(0, 1 - Math.max(0, t - 1) * 0.55);\n  });\n  const sheen = useTransform(rotation, (r) =>\n    Math.min(Math.abs(angle(r)) / step, 1) * 0.55\n  );\n\n  const bg =\n    card.bg ||\n    DEFAULT_CYLINDER_BACKGROUNDS[\n      index % DEFAULT_CYLINDER_BACKGROUNDS.length\n    ];\n\n  const renderContent = () => {\n    if (typeof card.content === \"function\") {\n      return card.content({ active, index });\n    }\n    if (card.content) {\n      return card.content;\n    }\n    if (typeof card.Body === \"function\") {\n      return <card.Body active={active} />;\n    }\n    if (card.children) {\n      return card.children;\n    }\n    return null;\n  };\n\n  const hasHeader = Boolean(card.title || card.pill);\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={card.title || `Card ${index + 1}`}\n      onClick={onSelect}\n      style={{\n        width: cardWidth,\n        height: cardHeight,\n        transform,\n        filter,\n        opacity,\n        background: bg,\n      }}\n      className=\"absolute left-0 top-0 cursor-grab select-none overflow-hidden rounded-[22px] shadow-[0_20px_50px_-20px_rgba(30,40,70,0.35)] [backface-visibility:hidden] active:cursor-grabbing\"\n    >\n      {/* header */}\n      {hasHeader && (\n        <div className=\"absolute inset-x-0 top-0 z-10 flex items-center justify-between px-5 pt-5\">\n          {card.title && (\n            <p\n              className={cn(\n                \"text-sm font-medium\",\n                card.dark ? \"text-neutral-900\" : \"text-white\"\n              )}\n            >\n              {card.title}\n            </p>\n          )}\n          {card.pill && <CylinderPill label={card.pill} />}\n        </div>\n      )}\n\n      {/* body */}\n      <div className={cn(\"absolute inset-0\", hasHeader ? \"pt-12\" : \"p-4\")}>\n        {renderContent()}\n      </div>\n\n      {/* progress dash, bottom centre */}\n      <div\n        className={cn(\n          \"absolute bottom-4 left-1/2 h-0.5 w-6 -translate-x-1/2 rounded-full\",\n          card.dark ? \"bg-neutral-900\" : \"bg-white\"\n        )}\n      />\n\n      {/* white sheen that grows as the card turns away */}\n      <motion.div\n        aria-hidden=\"true\"\n        style={{ opacity: sheen }}\n        className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-white/70 via-white/10 to-white/70\"\n      />\n    </motion.div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* CylinderCarousel Main Component                                    */\n/* ------------------------------------------------------------------ */\n\nexport function CylinderCarousel({\n  cards,\n  items,\n  cardWidth = 280,\n  cardHeight = 360,\n  gap = 70,\n  perspective = 1400,\n  initialIndex = 0,\n  springStiffness = 120,\n  damping = 20,\n  mass = 0.9,\n  showControls = true,\n  showDots = true,\n  className,\n  containerClassName,\n  ...props\n}: CylinderCarouselProps) {\n  const cardList = cards || items || [];\n  const reduceMotion = useReducedMotion();\n  const count = cardList.length;\n\n  const step = 360 / Math.max(count, 1);\n  const radius =\n    count > 2\n      ? (cardWidth + gap) / (2 * Math.tan(Math.PI / count))\n      : cardWidth + gap;\n\n  const rotation = useMotionValue(-initialIndex * step);\n  const [index, setIndex] = useState(initialIndex);\n  const activeCard = count > 0 ? ((index % count) + count) % count : 0;\n\n  const goTo = (i: number) => {\n    setIndex(i);\n    animate(\n      rotation,\n      -i * step,\n      reduceMotion\n        ? { duration: 0.01 }\n        : { type: \"spring\", stiffness: springStiffness, damping, mass }\n    );\n  };\n\n  // keyboard support\n  useEffect(() => {\n    if (count === 0) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"ArrowRight\") goTo(index + 1);\n      if (e.key === \"ArrowLeft\") goTo(index - 1);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [index, step, count]);\n\n  if (count === 0) {\n    return null;\n  }\n\n  return (\n    <section\n      className={cn(\n        \"relative flex min-h-[560px] w-full flex-col items-center justify-center overflow-hidden bg-[#e4e7ec] dark:bg-[#0c0d0e] py-14 transition-colors\",\n        className\n      )}\n      {...props}\n    >\n      <div\n        className={cn(\"relative w-full touch-pan-y\", containerClassName)}\n        style={{ height: cardHeight + 80, perspective }}\n      >\n        {/* drag surface */}\n        <motion.div\n          className=\"absolute inset-0 z-20 cursor-grab active:cursor-grabbing\"\n          onPan={(_, info) => {\n            rotation.stop();\n            rotation.set(rotation.get() + info.delta.x * 0.32);\n          }}\n          onPanEnd={(_, info) => {\n            const projected = rotation.get() + info.velocity.x * 0.04;\n            goTo(Math.round(-projected / step));\n          }}\n          onTap={(event, info: TapInfo) => {\n            const el = event.currentTarget as HTMLElement | null;\n            if (!el) return;\n            const rect = el.getBoundingClientRect();\n            const x = info.point.x - rect.left;\n            if (x < rect.width * 0.3) goTo(index - 1);\n            else if (x > rect.width * 0.7) goTo(index + 1);\n          }}\n          aria-hidden=\"true\"\n        />\n\n        {/* 3D cylinder stage */}\n        <div\n          className=\"absolute left-1/2 top-1/2\"\n          style={{\n            width: cardWidth,\n            height: cardHeight,\n            marginLeft: -cardWidth / 2,\n            marginTop: -cardHeight / 2,\n            transformStyle: \"preserve-3d\",\n            transform: `translateZ(${-radius}px)`,\n          }}\n        >\n          {cardList.map((card, i) => (\n            <CylinderCard\n              key={card.id ?? i}\n              card={card}\n              index={i}\n              step={step}\n              radius={radius}\n              cardWidth={cardWidth}\n              cardHeight={cardHeight}\n              rotation={rotation}\n              active={i === activeCard}\n              onSelect={() => {\n                const diff = normaliseAngle((i - activeCard) * step) / step;\n                goTo(index + Math.round(diff));\n              }}\n            />\n          ))}\n        </div>\n      </div>\n\n      {/* controls */}\n      {showControls && (\n        <div className=\"mt-8 flex items-center gap-4 z-30\">\n          <button\n            type=\"button\"\n            onClick={() => goTo(index - 1)}\n            aria-label=\"Previous card\"\n            className=\"flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-neutral-800 shadow-sm transition hover:bg-white dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900\"\n          >\n            <svg\n              viewBox=\"0 0 8 12\"\n              className=\"h-3 w-2 fill-none stroke-current\"\n              strokeWidth=\"1.6\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M6 1L1.5 6 6 11\" />\n            </svg>\n          </button>\n\n          {showDots && (\n            <div className=\"flex items-center gap-2\">\n              {cardList.map((c, i) => (\n                <button\n                  type=\"button\"\n                  key={c.id ?? i}\n                  aria-label={`Go to ${c.title || `Card ${i + 1}`}`}\n                  onClick={() =>\n                    goTo(\n                      index +\n                        Math.round(normaliseAngle((i - activeCard) * step) / step)\n                    )\n                  }\n                  className={cn(\n                    \"h-1.5 rounded-full transition-all duration-300\",\n                    i === activeCard\n                      ? \"w-6 bg-neutral-900 dark:bg-white\"\n                      : \"w-1.5 bg-neutral-400 dark:bg-neutral-600 hover:bg-neutral-600 dark:hover:bg-neutral-400\"\n                  )}\n                />\n              ))}\n            </div>\n          )}\n\n          <button\n            type=\"button\"\n            onClick={() => goTo(index + 1)}\n            aria-label=\"Next card\"\n            className=\"flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-neutral-800 shadow-sm transition hover:bg-white dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900\"\n          >\n            <svg\n              viewBox=\"0 0 8 12\"\n              className=\"h-3 w-2 fill-none stroke-current\"\n              strokeWidth=\"1.6\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M2 1l4.5 5L2 11\" />\n            </svg>\n          </button>\n        </div>\n      )}\n    </section>\n  );\n}\n\nexport default CylinderCarousel;\n",
      "type": "registry:ui",
      "target": "components/ui/cylinder-carousel.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
