{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-carousel",
  "title": "Testimonial Carousel",
  "author": "mrap10",
  "description": "Fan-style testimonial carousel with stacked cards and animated left/right controls.",
  "dependencies": ["motion"],
  "files": [
    {
      "path": "registry/default/testimonials/testimonial-carousel.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\ntype TestimonialItem = {\n  text: string;\n  company: string;\n  name: string;\n};\n\ntype TestimonialCarouselProps = {\n  title?: string | null;\n  items?: TestimonialItem[];\n  initialIndex?: number;\n  className?: string;\n  stageClassName?: string;\n  cardClassName?: string;\n  controlsClassName?: string;\n  buttonClassName?: string;\n};\n\nconst DEFAULT_TITLE = \"Community absolutely loves Refinery!\";\n\nconst DEFAULT_TESTIMONIALS: TestimonialItem[] = [\n  {\n    text: \"Refinery made it easier to assemble polished UI pieces quickly without fighting the design system.\",\n    company: \"TouYube\",\n    name: \"Ari Meadow\",\n  },\n  {\n    text: \"The component vault feels organized and practical, like having a clean starting point for every screen.\",\n    company: \"Toogle\",\n    name: \"Nora Vale\",\n  },\n  {\n    text: \"It is easy to find well-crafted building blocks here, which keeps the work moving instead of slowing down.\",\n    company: \"Metamime\",\n    name: \"Jules Haven\",\n  },\n  {\n    text: \"The spacing, hierarchy, and motion details feel thoughtful, so the final interface looks put together fast.\",\n    company: \"Z Combinatr\",\n    name: \"Mina Alder\",\n  },\n  {\n    text: \"Refinery gives the kind of reusable patterns that make dashboard work feel repeatable and calmer.\",\n    company: \"Slate Foundry\",\n    name: \"Eli Rowan\",\n  },\n  {\n    text: \"The cards and sections feel like a polished UI kit, but with just enough personality to stay memorable.\",\n    company: \"Pixel Combinator\",\n    name: \"Tess Marlow\",\n  },\n  {\n    text: \"It helps turn rough ideas into consistent layouts much faster, especially when the team needs a clean baseline.\",\n    company: \"Frame Forge\",\n    name: \"Owen Bright\",\n  },\n  {\n    text: \"The attention to interaction detail makes the whole library feel more finished than a typical component dump.\",\n    company: \"Grid Union\",\n    name: \"Lina Cross\",\n  },\n  {\n    text: \"Everything feels adaptable, so it is simple to shape a unique interface without rebuilding common patterns.\",\n    company: \"Atom Layer\",\n    name: \"Noah Finch\",\n  },\n  {\n    text: \"Refinery makes the UI workflow feel lighter, because the pieces already look coherent and production-ready.\",\n    company: \"Vanta Loom\",\n    name: \"Iris Bloom\",\n  },\n];\n\n// Each slot defines how a card looks at a given offset from center\nconst SLOT_CONFIG = [\n  { offset: -3, rotate: -28, scale: 0.62, opacity: 0.18, y: 70 },\n  { offset: -2, rotate: -18, scale: 0.74, opacity: 0.38, y: 38 },\n  { offset: -1, rotate: -9, scale: 0.86, opacity: 0.62, y: 14 },\n  { offset: 0, rotate: 0, scale: 1.0, opacity: 1.0, y: 0 },\n  { offset: 1, rotate: 9, scale: 0.86, opacity: 0.62, y: 14 },\n  { offset: 2, rotate: 18, scale: 0.74, opacity: 0.38, y: 38 },\n  { offset: 3, rotate: 28, scale: 0.62, opacity: 0.18, y: 70 },\n];\n\nconst VISIBLE_OFFSETS = SLOT_CONFIG.map((s) => s.offset);\nconst X_SPREAD = 108; // px between each slot horizontally\n\nfunction getSlot(offset: number) {\n  return SLOT_CONFIG.find((s) => s.offset === offset) ?? SLOT_CONFIG[0];\n}\n\nfunction getZIndex(offset: number) {\n  return 10 - Math.abs(offset);\n}\n\nexport default function TestimonialCarousel({\n  title = DEFAULT_TITLE,\n  items,\n  initialIndex,\n  className,\n  stageClassName,\n  cardClassName,\n  controlsClassName,\n  buttonClassName,\n}: TestimonialCarouselProps) {\n  const resolvedItems = items?.length ? items : DEFAULT_TESTIMONIALS;\n  const count = resolvedItems.length;\n  const [activeIndex, setActiveIndex] = useState(() => {\n    if (count === 0) return 0;\n    const fallbackIndex = Math.min(2, count - 1);\n    const rawIndex = initialIndex ?? fallbackIndex;\n    return ((rawIndex % count) + count) % count;\n  });\n\n  // Wraps the offset so the shortest path is always taken around the array\n  const getRelOffset = useCallback(\n    (cardIdx: number) => {\n      let diff = cardIdx - activeIndex;\n      if (diff > count / 2) diff -= count;\n      if (diff < -count / 2) diff += count;\n      return diff;\n    },\n    [activeIndex, count]\n  );\n\n  const prev = () => setActiveIndex((i) => (i - 1 + count) % count);\n  const next = () => setActiveIndex((i) => (i + 1) % count);\n\n  return (\n    <section\n      className={cn(\n        \"relative flex w-full flex-col items-center justify-center overflow-hidden bg-neutral-100 py-10 dark:bg-neutral-900\",\n        className\n      )}\n    >\n      {title ? (\n        <h3 className=\"text-center font-serif text-3xl font-medium tracking-tight text-neutral-700 dark:text-neutral-300\">\n          {title}\n        </h3>\n      ) : null}\n\n      <div\n        className={cn(\n          \"relative flex h-95 w-175 items-center justify-center\",\n          stageClassName\n        )}\n      >\n        {resolvedItems.map(({ text, company, name }, idx) => {\n          const offset = getRelOffset(idx);\n          if (!VISIBLE_OFFSETS.includes(offset)) return null;\n\n          const slot = getSlot(offset);\n          const zIndex = getZIndex(offset);\n          const isActive = offset === 0;\n\n          return (\n            <motion.div\n              key={`${name}-${idx}`}\n              onClick={() => !isActive && setActiveIndex(idx)}\n              animate={{\n                x: offset * X_SPREAD,\n                y: slot.y,\n                rotate: slot.rotate,\n                scale: slot.scale,\n                opacity: slot.opacity,\n                zIndex,\n              }}\n              transition={{\n                type: \"spring\",\n                stiffness: 240,\n                damping: 28,\n                mass: 0.9,\n              }}\n              className={cn(\n                \"absolute h-65 w-50 origin-bottom rounded-2xl transition-shadow duration-300 will-change-transform\",\n                isActive\n                  ? \"cursor-default bg-[#faf4ee] shadow-[0_12px_48px_rgba(60,40,20,0.18)] dark:bg-neutral-800 dark:shadow-[0_12px_48px_rgba(0,0,0,0.48)]\"\n                  : \"cursor-pointer bg-[#f0e8df] shadow-[0_3px_12px_rgba(60,40,20,0.08)] dark:bg-neutral-800/70 dark:shadow-[0_3px_12px_rgba(0,0,0,0.28)]\",\n                \"flex flex-col justify-center gap-5\",\n                \"px-5.5 py-7\",\n                cardClassName\n              )}\n            >\n              <p\n                aria-live={isActive ? \"polite\" : undefined}\n                className={cn(\n                  \"text-center font-serif leading-relaxed italic select-none\",\n                  isActive\n                    ? \"text-[15px] text-neutral-900 dark:text-neutral-100\"\n                    : \"text-[13px] text-neutral-700 dark:text-neutral-300\"\n                )}\n              >\n                {`\"${text}\"`}\n              </p>\n              <div className=\"flex items-center justify-end gap-1 text-[10px]\">\n                <span className=\"font-semibold text-neutral-700 dark:text-neutral-300\">\n                  - {name},\n                </span>\n                <span className=\"text-neutral-500 dark:text-neutral-400\">\n                  {company}\n                </span>\n              </div>\n            </motion.div>\n          );\n        })}\n      </div>\n\n      <div className={cn(\"mt-7 flex gap-5\", controlsClassName)}>\n        <button\n          type=\"button\"\n          onClick={prev}\n          aria-label=\"Previous testimonial\"\n          className={cn(\n            \"flex size-8 items-center justify-center rounded-full bg-[#f0e8df] text-base font-semibold text-neutral-600 transition-colors duration-200 hover:bg-[#eeeae6] active:scale-95 dark:bg-neutral-700 dark:text-neutral-500 hover:dark:bg-neutral-800\",\n            buttonClassName\n          )}\n        >\n          <ChevronLeft className=\"size-5\" />\n        </button>\n        <button\n          type=\"button\"\n          onClick={next}\n          aria-label=\"Next testimonial\"\n          className={cn(\n            \"flex size-8 items-center justify-center rounded-full bg-[#f0e8df] text-base font-semibold text-neutral-600 transition-colors duration-200 hover:bg-[#eeeae6] active:scale-95 dark:bg-neutral-700 dark:text-neutral-500 hover:dark:bg-neutral-800\",\n            buttonClassName\n          )}\n        >\n          <ChevronLeft className=\"size-5 rotate-180\" />\n        </button>\n      </div>\n    </section>\n  );\n}\n\nfunction ChevronLeft({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.25\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      <path d=\"M15 6l-6 6l6 6\" />\n    </svg>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": ["testimonials"],
  "type": "registry:component"
}
