{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-canvas",
  "title": "Testimonial Canvas",
  "author": "mrap10",
  "description": "A infinite canvas testimonial component inspired by godly.",
  "dependencies": ["motion"],
  "files": [
    {
      "path": "registry/default/testimonials/testimonial-canvas.tsx",
      "content": "\"use client\";\n\nimport { motion, useMotionValue, useSpring } from \"motion/react\";\nimport { useRef, useState } from \"react\";\n\nexport interface Testimonial {\n  id: string;\n  quote: string;\n  author: string;\n  meta?: string;\n  avatar?: string;\n}\n\nexport interface TestimonialCanvasProps {\n  testimonials: Testimonial[];\n  height?: string;\n  initialOffset?: { x: number; y: number };\n  hint?: string | false;\n}\n\nconst CARD_POSITIONS: [number, number][] = [\n  [0, 0],\n  [340, -60],\n  [-340, 80],\n  [160, 260],\n  [-200, -260],\n  [580, 260],\n  [-540, -160],\n  [200, -320],\n  [-200, 360],\n  [720, -100],\n  [-600, 300],\n  [500, 500],\n  [-500, -460],\n  [960, 360],\n  [-800, 50],\n];\n\nconst CARD_MARGIN_X = 60;\nconst CARD_MARGIN_Y = 60;\n\nconst vignette =\n  \"radial-gradient(ellipse 70% 70% at 50% 50%, transparent 40%, var(--vignette-color) 100%)\";\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction getPostion(index: number): [number, number] {\n  if (index < CARD_POSITIONS.length) return CARD_POSITIONS[index];\n\n  const angle = index * 137.5 * (Math.PI / 180);\n  const radius = 400 + index * 80;\n  return [Math.cos(angle) * radius, Math.sin(angle) * radius];\n}\n\nfunction getPanBounds(count: number) {\n  const xs: number[] = [];\n  const ys: number[] = [];\n\n  for (let i = 0; i < count; i++) {\n    const [x, y] = getPostion(i);\n    xs.push(x);\n    ys.push(y);\n  }\n\n  const maxPanX = (Math.max(...xs) - Math.min(...xs)) / 2 + CARD_MARGIN_X;\n  const maxPanY = (Math.max(...ys) - Math.min(...ys)) / 2 + CARD_MARGIN_Y;\n  return { maxPanX, maxPanY };\n}\n\nfunction TestimonialCard({\n  testimonial,\n  position,\n  index,\n}: {\n  testimonial: Testimonial;\n  position: [number, number];\n  index: number;\n}) {\n  return (\n    <motion.div\n      initial={{ opacity: 0, scale: 0.92 }}\n      animate={{ opacity: 1, scale: 1 }}\n      transition={{\n        duration: 0.5,\n        delay: index * 0.04,\n        ease: [0.16, 1, 0.3, 1],\n      }}\n      className=\"absolute w-72 rounded-2xl border border-black/8 bg-black/1 p-5 backdrop-blur-sm will-change-transform dark:border-white/8 dark:bg-white/3\"\n      style={{\n        left: position[0],\n        top: position[1],\n        transform: \"translate(-50%, -50%)\",\n      }}\n    >\n      <p className=\"text-sm leading-relaxed text-black/70 dark:text-white/70\">\n        &ldquo;{testimonial.quote}&rdquo;\n      </p>\n      <div className=\"mt-4 flex items-center gap-3\">\n        {testimonial.avatar ? (\n          <img\n            src={testimonial.avatar}\n            alt={testimonial.author}\n            className=\"size-7 rounded-full object-cover opacity-80 ring-1 ring-black/10 dark:ring-white/10\"\n          />\n        ) : (\n          <div className=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-black/5 text-[10px] font-medium text-black/50 uppercase dark:bg-white/10 dark:text-white/50\">\n            {testimonial.author.slice(0, 2)}\n          </div>\n        )}\n        <div className=\"min-w-0\">\n          <p className=\"truncate text-xs font-medium text-black/80 dark:text-white/80\">\n            {testimonial.author}\n          </p>\n          {testimonial.meta && (\n            <p className=\"truncate font-mono text-[10px] text-black/35 dark:text-white/35\">\n              {testimonial.meta}\n            </p>\n          )}\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n\nexport function TestimonialCanvas({\n  testimonials,\n  height = \"100vh\",\n  initialOffset = { x: 0, y: 0 },\n  hint = \"Drag to explore\",\n}: TestimonialCanvasProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const { maxPanX, maxPanY } = getPanBounds(testimonials.length);\n\n  const rawX = useMotionValue(clamp(initialOffset.x, -maxPanX, maxPanX));\n  const rawY = useMotionValue(clamp(initialOffset.y, -maxPanY, maxPanY));\n\n  const x = useSpring(rawX, { stiffness: 120, damping: 22, mass: 0.8 });\n  const y = useSpring(rawY, { stiffness: 120, damping: 22, mass: 0.8 });\n\n  const isDragging = useRef(false);\n  const lastPointer = useRef({ x: 0, y: 0 });\n  const [active, setActive] = useState(false);\n\n  function onPointerDown(e: React.PointerEvent) {\n    if (e.button !== 0) return;\n    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n    isDragging.current = true;\n    lastPointer.current = { x: e.clientX, y: e.clientY };\n    setActive(true);\n  }\n\n  function onPointerMove(e: React.PointerEvent) {\n    if (!isDragging.current) return;\n    const dx = e.clientX - lastPointer.current.x;\n    const dy = e.clientY - lastPointer.current.y;\n    lastPointer.current = { x: e.clientX, y: e.clientY };\n    rawX.set(clamp(rawX.get() + dx, -maxPanX, maxPanX));\n    rawY.set(clamp(rawY.get() + dy, -maxPanY, maxPanY));\n  }\n\n  function onPointerUp() {\n    isDragging.current = false;\n    setActive(false);\n  }\n\n  return (\n    <div\n      ref={containerRef}\n      className=\"relative overflow-hidden bg-neutral-100 [--vignette-color:var(--color-neutral-50)] dark:bg-neutral-950 dark:[--vignette-color:var(--color-neutral-950)]\"\n      style={{ height }}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerLeave={onPointerUp}\n    >\n      <div\n        className=\"pointer-events-none absolute inset-0 z-10\"\n        style={{\n          background: vignette,\n        }}\n      />\n\n      <motion.div\n        className=\"absolute top-1/2 left-1/2\"\n        style={{ x, y, cursor: active ? \"grabbing\" : \"grab\" }}\n      >\n        {testimonials.map((testimonial, index) => (\n          <TestimonialCard\n            key={testimonial.id}\n            testimonial={testimonial}\n            position={getPostion(index)}\n            index={index}\n          />\n        ))}\n      </motion.div>\n\n      {hint && (\n        <motion.div\n          initial={{ opacity: 0, y: 6 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ duration: 0.5, delay: 0.8 }}\n          className=\"pointer-events-none absolute bottom-6 left-1/2 z-30 -translate-x-1/2 rounded-full border border-black/8 bg-black/1 px-4 py-1.5 font-mono text-[11px] text-black/30 backdrop-blur-sm dark:border-white/8 dark:bg-white/4 dark:text-white/30\"\n        >\n          {hint}\n        </motion.div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": ["testimonials"],
  "type": "registry:component"
}
