{ "name": "testimonial", "type": "registry:ui", "registryDependencies": [], "dependencies": [ "motion", "lucide-react" ], "devDependencies": [], "tailwind": {}, "cssVars": { "light": {}, "dark": {} }, "files": [ { "path": "testimonial.tsx", "content": "'use client';\n\nimport * as React from 'react';\nimport { motion, AnimatePresence } from 'motion/react';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { cn } from '@/lib/utils';\n\nexport type TestimonialData = {\n id?: React.Key;\n avatar?: string; // img url\n name?: string;\n role?: string;\n quote?: string;\n accent?: string; // e.g. '#3b82f6' or 'rgb(...)'\n // Supply your own node instead of quote card (gets active state + index)\n render?: (ctx: { active: boolean; index: number }) => React.ReactNode;\n};\n\nexport type TestimonialCarouselProps = {\n items: TestimonialData[];\n\n /** visual variant for default renderer */\n variant?: 'card' | 'minimal';\n /** looping & autoplay */\n loop?: boolean;\n autoplay?: boolean;\n autoplayMs?: number;\n\n /** controls */\n showArrows?: boolean;\n showDots?: boolean;\n allowKeyboard?: boolean;\n\n /** callbacks */\n initialIndex?: number;\n onIndexChange?: (index: number) => void;\n\n /** slot-style class overrides */\n className?: string; // wrapper\n contentClassName?: string; // inner panel\n cardClassName?: string;\n avatarClassName?: string;\n nameClassName?: string;\n roleClassName?: string;\n quoteClassName?: string;\n\n /** aria */\n ariaLabel?: string;\n};\n\nexport function TestimonialCarousel({\n items,\n variant = 'card',\n loop = true,\n autoplay = false,\n autoplayMs = 5000,\n showArrows = true,\n showDots = true,\n allowKeyboard = true,\n initialIndex = 0,\n onIndexChange,\n className,\n contentClassName,\n cardClassName,\n avatarClassName,\n nameClassName,\n roleClassName,\n quoteClassName,\n ariaLabel = 'Testimonials',\n}: TestimonialCarouselProps) {\n const count = items.length;\n const [index, setIndex] = React.useState(\n Math.min(Math.max(initialIndex, 0), Math.max(count - 1, 0))\n );\n const active = ((i: number) => ((i % count) + count) % count)(index); // safe modulo\n\n const go = React.useCallback(\n (delta: number) => {\n setIndex((i) => {\n const next = i + delta;\n if (!loop) {\n const clamped = Math.min(Math.max(next, 0), count - 1);\n onIndexChange?.(clamped);\n return clamped;\n }\n const wrapped = ((next % count) + count) % count;\n onIndexChange?.(wrapped);\n return wrapped;\n });\n },\n [count, loop, onIndexChange]\n );\n\n // autoplay\n React.useEffect(() => {\n if (!autoplay || count <= 1) return;\n const id = setInterval(() => go(1), autoplayMs);\n return () => clearInterval(id);\n }, [autoplay, autoplayMs, count, go]);\n\n // keyboard\n React.useEffect(() => {\n if (!allowKeyboard) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'ArrowRight') go(1);\n if (e.key === 'ArrowLeft') go(-1);\n };\n window.addEventListener('keydown', onKey);\n return () => window.removeEventListener('keydown', onKey);\n }, [allowKeyboard, go]);\n\n return (\n \n {/* arrows */}\n {showArrows && count > 1 && (\n <>\n go(-1)}\n aria-label='Previous testimonial'\n className='absolute top-1/2 left-3 -translate-y-1/2 rounded-full p-2 text-zinc-500 ring-1 ring-zinc-200 transition hover:bg-zinc-100 hover:text-zinc-800 dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800'\n >\n \n \n go(1)}\n aria-label='Next testimonial'\n className='absolute top-1/2 right-3 -translate-y-1/2 rounded-full p-2 text-zinc-500 ring-1 ring-zinc-200 transition hover:bg-zinc-100 hover:text-zinc-800 dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800'\n >\n \n \n \n )}\n\n {/* slide */}\n
\n \n \n {/* custom renderer? */}\n {typeof items[active]?.render === 'function' ? (\n items[active].render!({ active: true, index: active })\n ) : (\n \n )}\n \n \n
\n\n {/* dots */}\n {showDots && count > 1 && (\n
\n {items.map((_, i) => {\n const is = i === active;\n return (\n setIndex(i)}\n className={cn(\n 'h-2.5 w-2.5 rounded-full ring-1 ring-zinc-300 transition dark:ring-zinc-700',\n is\n ? 'bg-zinc-900 dark:bg-zinc-100'\n : 'bg-zinc-300/70 dark:bg-zinc-700/70'\n )}\n />\n );\n })}\n
\n )}\n \n );\n}\n\n/* ---------- default card renderer ---------- */\n\nfunction DefaultCard({\n item,\n variant,\n className,\n avatarClassName,\n nameClassName,\n roleClassName,\n quoteClassName,\n}: {\n item: TestimonialData;\n variant: 'card' | 'minimal';\n className?: string;\n avatarClassName?: string;\n nameClassName?: string;\n roleClassName?: string;\n quoteClassName?: string;\n}) {\n const accent = item.accent ?? '#3b82f6'; // tailwind blue-500\n const ring = { boxShadow: `0 0 0 3px ${accent}22` };\n\n return (\n \n {/* avatar */}\n {(item.avatar || item.name) && (\n
\n {item.avatar ? (\n \n ) : (\n \n \n {item.name?.[0] ?? '?'}\n \n
\n )}\n {/* decorative quote badge */}\n \n "\n \n \n )}\n\n {/* name / role */}\n {item.name && (\n \n {item.name}\n \n )}\n {item.role && (\n \n {item.role}\n

\n )}\n\n {/* quote */}\n {item.quote && (\n \n {item.quote}\n

\n )}\n \n );\n}\n", "type": "registry:ui" } ] }