{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "case-study-tabs", "type": "registry:ui", "title": "Case Study Tabs", "description": "A tabbed customer-testimonial block with embossed stars, brand-logo tabs, an animated underline indicator that adopts each brand's accent color, a featured case-study paragraph with CTA, and a pull-quote with author.", "dependencies": [ "lucide-react" ], "registryDependencies": [ "tabs" ], "files": [ { "path": "registry/ruixenui/case-study-tabs.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport Link from \"next/link\";\nimport { ChevronRight, Star } from \"lucide-react\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── types ───────────────────────────────────────────────────── */\n\nexport interface CaseStudyTabsBrand {\n /** Unique tab id. */\n id: string;\n /** Accessible label for screen readers. */\n name: string;\n /** Logo node — typically an inline SVG. */\n logo: React.ReactNode;\n /** CSS color string used for the animated underline indicator. Defaults to `currentColor`. */\n accentColor?: string;\n}\n\nexport interface CaseStudyAuthor {\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 CaseStudy {\n brand: CaseStudyTabsBrand;\n /** Featured headline paragraph rendered in large type. Omit to render testimonial-only. */\n headline?: React.ReactNode;\n /** Optional \"Read case study\" CTA. */\n cta?: { label: string; href: string };\n /** Pull-quote rendered with curly-quote pseudo-elements. */\n quote: React.ReactNode;\n author: CaseStudyAuthor;\n}\n\nexport interface CaseStudyTabsProps {\n cases: CaseStudy[];\n defaultCaseId?: string;\n /** Decorative embossed stars rendered above the tab bar. Set to 0 to hide. */\n starCount?: number;\n className?: string;\n}\n\n/* ── stars row ───────────────────────────────────────────────── */\n\nfunction StarsRow({ count }: { count: number }) {\n return (\n
\n {Array.from({ length: count }, (_, i) => (\n \n ))}\n
\n );\n}\n\n/* ── avatar ──────────────────────────────────────────────────── */\n\nfunction AuthorAvatar({ author }: { author: CaseStudyAuthor }) {\n const frame =\n \"aspect-square size-10 overflow-hidden rounded-lg border border-transparent shadow-md 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 fallback =\n author.avatarFallback ?? author.name.trim().charAt(0).toUpperCase();\n\n return (\n \n {fallback}\n \n );\n}\n\n/* ── component ───────────────────────────────────────────────── */\n\nexport function CaseStudyTabs({\n cases,\n defaultCaseId,\n starCount = 5,\n className,\n}: CaseStudyTabsProps) {\n const firstId = cases[0]?.brand.id ?? \"\";\n const [activeId, setActiveId] = React.useState(defaultCaseId ?? firstId);\n\n const allTestimonialOnly = cases.every((c) => !c.headline && !c.cta);\n\n const tabBarRef = React.useRef(null);\n const [indicator, setIndicator] = React.useState<{\n x: number;\n w: number;\n } | null>(null);\n\n const activeCase = cases.find((c) => c.brand.id === activeId) ?? cases[0];\n\n React.useLayoutEffect(() => {\n const container = tabBarRef.current;\n if (!container) return;\n\n const measure = () => {\n const activeBtn = container.querySelector(\n '[data-state=\"active\"]',\n );\n if (activeBtn) {\n setIndicator({\n x: activeBtn.offsetLeft,\n w: activeBtn.offsetWidth,\n });\n }\n };\n\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(container);\n return () => observer.disconnect();\n }, [activeId]);\n\n if (!activeCase) return null;\n\n return (\n \n
\n {starCount > 0 && (\n
\n \n
\n )}\n\n \n \n {/* fading top + bottom border */}\n \n\n {/* animated underline indicator */}\n \n\n \n {cases.map((c) => (\n \n {c.brand.logo}\n \n ))}\n \n
\n\n {cases.map((c) => {\n const hasTopBlock = Boolean(c.headline || c.cta);\n return (\n \n {hasTopBlock && (\n
\n {c.headline && (\n

\n {c.headline}\n

\n )}\n {c.cta && (\n \n {c.cta.label}\n \n \n )}\n
\n )}\n\n
\n \n {c.quote}\n

\n \n \n
\n

\n {c.author.name}\n

\n

\n {c.author.role}\n

\n
\n
\n \n
\n );\n })}\n \n \n \n );\n}\n\nexport default CaseStudyTabs;\n", "type": "registry:ui", "target": "components/ruixen/case-study-tabs.tsx" } ] }