{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "featured-portrait-testimonial", "type": "registry:ui", "title": "Featured Portrait Testimonial", "description": "An expanding-portrait testimonial carousel. Inactive cards show a grayscale portrait with a name strip; the active card morphs into a full quote card with author, dashed divider, and a row of favorite-feature tags. Width transitions and content crossfade are choreographed for clean, premium movement.", "dependencies": [ "lucide-react" ], "registryDependencies": [ "avatar" ], "files": [ { "path": "registry/ruixenui/featured-portrait-testimonial.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/utils\";\n\n/* ── types ───────────────────────────────────────────────────── */\n\nexport interface FeaturedPortraitFavorite {\n icon: React.ReactNode;\n label: React.ReactNode;\n}\n\nexport interface FeaturedPortraitAuthor {\n name: string;\n role: string;\n avatarUrl?: string;\n}\n\nexport interface FeaturedPortraitCompany {\n /** Subtitle rendered under the name in the inactive card footer. */\n name?: string;\n /** Tiny logo bubble drawn to the left of the avatar in the inactive footer. */\n logo?: React.ReactNode;\n}\n\nexport interface FeaturedPortraitItem {\n id: string;\n /** Pull-quote shown in the active (left-half) card. */\n quote: React.ReactNode;\n author: FeaturedPortraitAuthor;\n /** Portrait image used in the inactive card. */\n portraitUrl: string;\n /** Optional company subtitle + tiny logo bubble for the inactive footer. */\n company?: FeaturedPortraitCompany;\n /** Optional favorite-feature tags rendered under the dashed divider in the active card. */\n favorites?: FeaturedPortraitFavorite[];\n /** Tailwind classes overriding the active-card background. */\n accentClassName?: string;\n /** Tailwind classes overriding the inactive portrait container background tint. */\n portraitBgClassName?: string;\n}\n\nexport interface FeaturedPortraitTestimonialProps {\n items: FeaturedPortraitItem[];\n /** Small pill rendered above the heading. */\n eyebrow?: React.ReactNode;\n heading?: React.ReactNode;\n description?: React.ReactNode;\n /** Caption rendered above the favorites tag row in the active card. */\n favoritesLabel?: React.ReactNode;\n /** Initial active index. Default 0. */\n defaultIndex?: number;\n /** Width of each inactive portrait card in px. Default 240. */\n inactiveCardWidth?: number;\n className?: string;\n}\n\n/* ── motion constants ────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.32, 0.72, 0, 1)\";\nconst SLIDE_MS = 650;\nconst FADE_MS = 450;\nconst GAP_PX = 16;\n\n/* ── component ───────────────────────────────────────────────── */\n\nexport function FeaturedPortraitTestimonial({\n items,\n eyebrow,\n heading,\n description,\n favoritesLabel = \"Favorites Feature\",\n defaultIndex = 0,\n inactiveCardWidth = 240,\n className,\n}: FeaturedPortraitTestimonialProps) {\n const total = items.length;\n const [activeIndex, setActiveIndex] = React.useState(() =>\n Math.min(Math.max(defaultIndex, 0), Math.max(0, total - 1)),\n );\n\n const activeItem = items[activeIndex];\n\n const goNext = React.useCallback(() => {\n if (total === 0) return;\n setActiveIndex((i) => (i + 1) % total);\n }, [total]);\n\n const goPrev = React.useCallback(() => {\n if (total === 0) return;\n setActiveIndex((i) => (i - 1 + total) % total);\n }, [total]);\n\n const onKey = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"ArrowRight\") {\n e.preventDefault();\n goNext();\n } else if (e.key === \"ArrowLeft\") {\n e.preventDefault();\n goPrev();\n }\n },\n [goNext, goPrev],\n );\n\n if (total === 0 || !activeItem) return null;\n\n return (\n \n
\n {(eyebrow || heading || description) && (\n
\n {eyebrow && (\n \n {eyebrow}\n \n )}\n {heading && (\n

\n {heading}\n

\n )}\n {description && (\n

\n {description}\n

\n )}\n
\n )}\n\n \n \n \n
\n \n \n );\n}\n\n/* ── left half — active card with two-layer crossfade ────────── */\n\nfunction ActiveColumn({\n activeItem,\n favoritesLabel,\n}: {\n activeItem: FeaturedPortraitItem;\n favoritesLabel: React.ReactNode;\n}) {\n const [state, setState] = React.useState<{\n current: FeaturedPortraitItem;\n outgoing: FeaturedPortraitItem | null;\n }>({ current: activeItem, outgoing: null });\n\n React.useEffect(() => {\n if (state.current.id !== activeItem.id) {\n setState({ current: activeItem, outgoing: state.current });\n const t = setTimeout(() => {\n setState((prev) => ({ ...prev, outgoing: null }));\n }, FADE_MS + 60);\n return () => clearTimeout(t);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [activeItem.id]);\n\n return (\n
\n {/* Current — fades 0 → 1, drives the column height. */}\n \n \n
\n\n {/* Outgoing — overlays inset-0 and fades 1 → 0. */}\n {state.outgoing && (\n \n \n \n )}\n \n );\n}\n\n/* ── right half — inactive cards (persistent DOM) + arrows ───── */\n\nfunction InactivesColumn({\n items,\n activeIndex,\n width,\n onSelect,\n goPrev,\n goNext,\n}: {\n items: FeaturedPortraitItem[];\n activeIndex: number;\n width: number;\n onSelect: (i: number) => void;\n goPrev: () => void;\n goNext: () => void;\n}) {\n const total = items.length;\n const step = width + GAP_PX;\n\n // Card height derivation: outer p-2 (16) + image (aspect-[5/6]) + footer (~60).\n const imageInner = width - 16;\n const imageHeight = imageInner * (6 / 5);\n const containerHeight = Math.round(16 + imageHeight + 60 + 4);\n\n // Track the item that just stopped being active so we can teleport-and-fade\n // it into the rightmost slot instead of letting its transform slide all the\n // way across the row.\n const [justInactiveId, setJustInactiveId] = React.useState(\n null,\n );\n const prevActiveIdRef = React.useRef(\n items[activeIndex]?.id,\n );\n\n React.useEffect(() => {\n const currentId = items[activeIndex]?.id;\n const prevId = prevActiveIdRef.current;\n if (prevId && prevId !== currentId) {\n setJustInactiveId(prevId);\n prevActiveIdRef.current = currentId;\n const t = setTimeout(() => setJustInactiveId(null), SLIDE_MS + 60);\n return () => clearTimeout(t);\n }\n }, [activeIndex, items]);\n\n return (\n
\n \n {items.map((item, i) => {\n const pos = (i - activeIndex + total) % total;\n const isActive = pos === 0;\n const isJustInactive = item.id === justInactiveId;\n\n // pos 0 (active) parks at translateX(0) — same slot as pos 1. It's\n // hidden via opacity, so the overlap is invisible. Parking there\n // makes \"becoming active\" a pure fade-out in place, no jitter.\n const x = isActive ? 0 : (pos - 1) * step;\n\n // - Just-became-inactive: skip the transform transition so the card\n // jumps to the rightmost slot invisibly (it was already opacity 0\n // there) and only fades in. No sliding across the visible row.\n // - Active: only opacity transitions matter; no visible motion.\n // - Everyone else: smooth transform slide + light opacity blend.\n const transition = isJustInactive\n ? `opacity ${FADE_MS}ms ${EASE}`\n : `transform ${SLIDE_MS}ms ${EASE}, opacity ${FADE_MS}ms ${EASE}`;\n\n return (\n \n onSelect(i)}\n />\n
\n );\n })}\n \n\n
\n \n \n
\n \n );\n}\n\n/* ── active card ─────────────────────────────────────────────── */\n\nfunction ActiveCard({\n item,\n favoritesLabel,\n}: {\n item: FeaturedPortraitItem;\n favoritesLabel: React.ReactNode;\n}) {\n const hasFavorites = !!item.favorites && item.favorites.length > 0;\n return (\n \n \n {item.quote}\n

\n\n
\n \n {item.author.avatarUrl && (\n \n )}\n \n {item.author.name.charAt(0).toUpperCase()}\n \n \n
\n

\n {item.author.name}\n

\n

\n {item.author.role}\n

\n
\n
\n\n {hasFavorites && (\n <>\n \n

{favoritesLabel}

\n
    \n {item.favorites!.map((fav, i) => (\n \n \n {fav.icon}\n \n {fav.label}\n \n ))}\n
\n \n )}\n \n );\n}\n\n/* ── inactive card ───────────────────────────────────────────── */\n\nfunction InactiveCard({\n item,\n width,\n onClick,\n}: {\n item: FeaturedPortraitItem;\n width: number;\n onClick: () => void;\n}) {\n return (\n \n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n \n\n
\n {item.company?.logo && (\n \n {item.company.logo}\n \n )}\n \n {item.author.avatarUrl && (\n \n )}\n \n {item.author.name.charAt(0).toUpperCase()}\n \n \n
\n

\n {item.author.name}\n

\n

\n {item.company?.name ?? item.author.role}\n

\n
\n
\n \n );\n}\n\n/* ── nav button ──────────────────────────────────────────────── */\n\nfunction NavButton({\n onClick,\n direction,\n}: {\n onClick: () => void;\n direction: \"prev\" | \"next\";\n}) {\n const Icon = direction === \"prev\" ? ChevronLeft : ChevronRight;\n return (\n \n \n \n );\n}\n\nexport default FeaturedPortraitTestimonial;\n", "type": "registry:ui", "target": "components/ruixen/featured-portrait-testimonial.tsx" } ] }