{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "customer-story-stack", "type": "registry:ui", "title": "Customer Story Stack", "description": "A card-stack customer testimonial carousel with vertical prev/next nav, touch-swipe support, two ghost cards layered behind the active story, and a framed metrics panel with corner crosshair marks that updates per case.", "dependencies": [ "lucide-react" ], "files": [ { "path": "registry/ruixenui/customer-story-stack.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport Link from \"next/link\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── types ───────────────────────────────────────────────────── */\n\nexport interface CustomerStoryAuthor {\n name: string;\n role: string;\n /** Image URL for the avatar. */\n avatarUrl?: string;\n /** Initials/letter fallback when `avatarUrl` is omitted. */\n avatarFallback?: string;\n}\n\nexport interface CustomerStoryMetric {\n /** Single icon rendered above the metric label. */\n icon: React.ReactNode;\n /** Label rendered below the icon. */\n label: React.ReactNode;\n}\n\nexport interface CustomerStoryCase {\n /** Unique id used as the React key when cycling cards. */\n id: string;\n /** Brand logo — typically an inline SVG. */\n logo: React.ReactNode;\n /** Pull-quote rendered with serif curly-quote pseudo-elements. */\n quote: React.ReactNode;\n author: CustomerStoryAuthor;\n /** Two metrics shown inside the same frame, below the testimonial. Omit to hide. */\n metrics?: [CustomerStoryMetric, CustomerStoryMetric];\n}\n\nexport interface CustomerStoryStackProps {\n cases: CustomerStoryCase[];\n /** Optional \"Read more customer stories\" link rendered below the frame. */\n readMoreLink?: { label: string; href: string };\n /** Horizontal swipe threshold in pixels for touch-based prev/next. */\n swipeThreshold?: number;\n className?: string;\n}\n\n/* ── primitives ──────────────────────────────────────────────── */\n\nfunction Crosshair({ className }: { className?: string }) {\n return (\n \n );\n}\n\nfunction FrameLines() {\n const lineH =\n \"pointer-events-none absolute left-0 h-px w-full bg-[linear-gradient(to_right,_transparent_0%,_var(--color-border)_6.5%,_var(--color-border)_93.5%,_transparent_100%)]\";\n const lineV =\n \"pointer-events-none absolute top-0 h-full w-px bg-[linear-gradient(to_bottom,_transparent_0%,_var(--color-border)_6.5%,_var(--color-border)_93.5%,_transparent_100%)]\";\n return (\n <>\n
\n
\n
\n
\n \n \n \n \n \n );\n}\n\nfunction AuthorAvatar({ author }: { author: CustomerStoryAuthor }) {\n const frame =\n \"aspect-square size-11 overflow-hidden rounded-xl border border-transparent shadow-sm shadow-black/15 ring-1 ring-foreground/10\";\n\n if (author.avatarUrl) {\n return (\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n
\n );\n }\n\n const initials =\n author.avatarFallback ?? author.name.trim().charAt(0).toUpperCase();\n\n return (\n \n {initials}\n
\n );\n}\n\n/* ── component ───────────────────────────────────────────────── */\n\nexport function CustomerStoryStack({\n cases,\n readMoreLink,\n swipeThreshold = 50,\n className,\n}: CustomerStoryStackProps) {\n const [activeIndex, setActiveIndex] = React.useState(0);\n const total = cases.length;\n const surfaceRef = React.useRef(null);\n const touchStartX = React.useRef(null);\n\n const goNext = React.useCallback(() => {\n setActiveIndex((i) => (i + 1) % total);\n }, [total]);\n\n const goPrev = React.useCallback(() => {\n setActiveIndex((i) => (i - 1 + total) % total);\n }, [total]);\n\n React.useEffect(() => {\n const surface = surfaceRef.current;\n if (!surface) return;\n\n const onStart = (e: TouchEvent) => {\n touchStartX.current = e.touches[0]?.clientX ?? null;\n };\n const onEnd = (e: TouchEvent) => {\n const start = touchStartX.current;\n touchStartX.current = null;\n if (start == null) return;\n const end = e.changedTouches[0]?.clientX ?? start;\n const dx = end - start;\n if (Math.abs(dx) <= swipeThreshold) return;\n if (dx > 0) goPrev();\n else goNext();\n };\n\n surface.addEventListener(\"touchstart\", onStart, { passive: true });\n surface.addEventListener(\"touchend\", onEnd, { passive: true });\n return () => {\n surface.removeEventListener(\"touchstart\", onStart);\n surface.removeEventListener(\"touchend\", onEnd);\n };\n }, [goNext, goPrev, swipeThreshold]);\n\n if (total === 0) return null;\n const activeCase = cases[activeIndex];\n if (!activeCase) return null;\n\n const navBtn =\n \"inline-flex size-9 items-center justify-center rounded-full border border-transparent bg-card shadow-sm shadow-black/10 ring-1 ring-foreground/10 transition-colors duration-200 hover:bg-muted/50 disabled:pointer-events-none disabled:opacity-50 dark:ring-foreground/15 dark:hover:bg-muted/50\";\n\n return (\n \n \n \n\n \n
\n
\n {activeCase.logo}\n
\n\n

\n {activeCase.quote}\n

\n\n
\n \n
\n

\n {activeCase.author.name}\n

\n

\n {activeCase.author.role}\n

\n
\n
\n
\n\n {activeCase.metrics && (\n
\n {activeCase.metrics.map((metric, i) => (\n \n \n {metric.icon}\n
\n

\n {metric.label}\n

\n
\n ))}\n
\n )}\n
\n \n\n
\n \n \n \n\n
\n {cases.map((c, idx) => (\n setActiveIndex(idx)}\n aria-label={`Show story ${idx + 1}`}\n aria-current={idx === activeIndex ? \"true\" : undefined}\n className={cn(\n \"size-2 rounded-full transition-colors\",\n idx === activeIndex\n ? \"bg-foreground\"\n : \"bg-foreground/25 hover:bg-foreground/50\",\n )}\n />\n ))}\n
\n\n \n \n \n
\n\n {readMoreLink && (\n \n {readMoreLink.label}\n \n \n )}\n \n );\n}\n\nexport default CustomerStoryStack;\n", "type": "registry:ui", "target": "components/ruixen/customer-story-stack.tsx" } ] }