{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"testimonial-wall","type":"registry:block","title":"Testimonial Wall","description":"Testimonial wall of quote cards with names, roles, initials avatars and optional star ratings, laid out in masonry-like columns that scroll vertically in alternating directions behind fade masks, pause on hover or focus, fall back to a static masonry under reduced motion, offer a static grid variant, and pick their column count from the block's own container width.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/testimonial-wall.tsx","type":"registry:component","target":"@components/motion/testimonial-wall.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/testimonial-wall\n\nimport { Star } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { Marquee } from \"@/components/motion/marquee\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Testimonial {\n  id: string;\n  quote: ReactNode;\n  name: string;\n  role?: ReactNode;\n  /** Image URL. Falls back to initials derived from `name`. */\n  avatar?: string;\n  /** Override the derived initials. */\n  initials?: string;\n  /** 0–5 stars. Omit to hide the rating row. */\n  rating?: number;\n}\n\nexport interface TestimonialWallProps {\n  testimonials: Testimonial[];\n  /** \"marquee\" scrolls columns in alternating directions; \"grid\" is static. */\n  variant?: \"marquee\" | \"grid\";\n  /** Maximum number of columns at the widest container size (1–4). Default 3. */\n  columns?: 1 | 2 | 3 | 4;\n  /** Seconds for one loop of the first column. Others vary slightly. Default 40. */\n  speed?: number;\n  /** Height of the marquee viewport. Default 32rem. Ignored by `grid`. */\n  height?: number | string;\n  /** Pause a column while it is hovered or holds focus. Default true. */\n  pauseOnHover?: boolean;\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  description?: ReactNode;\n  className?: string;\n}\n\n// Show/hide classes for the 1/2/3/4-lane layouts. Lane counts step up with\n// the block's own container width (not the viewport): 2 at @md, 3 at @3xl,\n// 4 at @5xl. Spelled out in full so Tailwind can see every class.\nconst SHOW_AT = [\"flex\", \"hidden @md:flex\", \"hidden @3xl:flex\", \"hidden @5xl:flex\"];\nconst HIDE_AT = [\"\", \"@md:hidden\", \"@3xl:hidden\", \"@5xl:hidden\"];\nconst GRID_COLUMNS = [\n  \"columns-1\",\n  \"columns-1 @md:columns-2\",\n  \"columns-1 @md:columns-2 @3xl:columns-3\",\n  \"columns-1 @md:columns-2 @3xl:columns-3 @5xl:columns-4\",\n];\n\nconst FADE_MASK =\n  \"[mask-image:linear-gradient(to_bottom,transparent,black_14%,black_86%,transparent)]\";\n\nfunction initialsOf(name: string) {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => part[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n}\n\n/** Deterministic palette slot so a person keeps the same avatar tint. */\nfunction tintOf(name: string) {\n  let hash = 0;\n  for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;\n  return (hash % 5) + 1;\n}\n\nfunction distribute<T>(items: T[], lanes: number): T[][] {\n  const out: T[][] = Array.from({ length: lanes }, () => []);\n  items.forEach((item, index) => {\n    out[index % lanes]?.push(item);\n  });\n  return out;\n}\n\n/** Repeat a short lane until it has at least `min` cards. */\nfunction fillLane<T>(lane: T[], min = 4) {\n  const out: { item: T; copy: number }[] = lane.map((item) => ({ item, copy: 0 }));\n  if (lane.length === 0) return out;\n  let copy = 1;\n  while (out.length < min) {\n    for (const item of lane) {\n      if (out.length >= min) break;\n      out.push({ item, copy });\n    }\n    copy += 1;\n  }\n  return out;\n}\n\nexport function TestimonialCard({\n  testimonial,\n  className,\n}: {\n  testimonial: Testimonial;\n  className?: string;\n}) {\n  const { quote, name, role, avatar, rating } = testimonial;\n  const tint = tintOf(name);\n  const stars =\n    rating === undefined ? null : Math.max(0, Math.min(5, Math.round(rating)));\n\n  return (\n    <figure\n      className={cn(\n        \"flex w-full flex-col gap-4 rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-xs transition-colors hover:border-border-strong\",\n        className,\n      )}\n    >\n      {stars !== null ? (\n        <div className=\"flex items-center gap-0.5\">\n          <span className=\"sr-only\">{`Rated ${stars} out of 5`}</span>\n          {[0, 1, 2, 3, 4].map((i) => (\n            <Star\n              key={i}\n              aria-hidden\n              className={cn(\n                \"size-3.5\",\n                i < stars\n                  ? \"fill-(--chart-4) text-(--chart-4)\"\n                  : \"fill-muted text-muted\",\n              )}\n            />\n          ))}\n        </div>\n      ) : null}\n      <blockquote className=\"text-pretty text-sm leading-6 text-foreground\">\n        {quote}\n      </blockquote>\n      <figcaption className=\"flex items-center gap-3\">\n        {avatar ? (\n          // biome-ignore lint/performance/noImgElement: library component; consumers may not use next/image.\n          <img\n            src={avatar}\n            alt=\"\"\n            width={36}\n            height={36}\n            loading=\"lazy\"\n            className=\"size-9 shrink-0 rounded-full object-cover\"\n          />\n        ) : (\n          <span\n            aria-hidden\n            style={\n              {\n                \"--tint\": `var(--chart-${tint})`,\n              } as CSSProperties\n            }\n            className=\"grid size-9 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--tint)_22%,transparent)] text-xs font-semibold text-foreground ring-1 ring-[color-mix(in_oklab,var(--tint)_45%,transparent)]\"\n          >\n            {testimonial.initials ?? initialsOf(name)}\n          </span>\n        )}\n        <div className=\"min-w-0\">\n          <div className=\"truncate text-sm font-medium text-foreground\">\n            {name}\n          </div>\n          {role ? (\n            <div className=\"truncate text-xs text-muted-foreground\">{role}</div>\n          ) : null}\n        </div>\n      </figcaption>\n    </figure>\n  );\n}\n\n/**\n * Testimonial wall: masonry-like columns of quote cards. The marquee variant\n * scrolls each column vertically in alternating directions behind top and\n * bottom fade masks, pausing a column on hover or focus; reduced motion and\n * `variant=\"grid\"` render the same cards as a static masonry. Column count\n * follows the block's own container width.\n */\nexport function TestimonialWall({\n  testimonials,\n  variant = \"marquee\",\n  columns = 3,\n  speed = 40,\n  height = \"32rem\",\n  pauseOnHover = true,\n  eyebrow,\n  heading,\n  description,\n  className,\n}: TestimonialWallProps) {\n  const reduce = useReducedMotion();\n  const maxLanes = Math.max(1, Math.min(4, columns));\n  const animated = variant === \"marquee\" && !reduce;\n\n  const header =\n    eyebrow || heading || description ? (\n      <div className=\"mx-auto mb-8 flex max-w-xl flex-col items-center gap-3 text-center\">\n        {eyebrow ? (\n          <p className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n            {eyebrow}\n          </p>\n        ) : null}\n        {heading ? (\n          <h2 className=\"text-balance text-2xl font-semibold leading-tight tracking-tight text-foreground @3xl:text-3xl\">\n            {heading}\n          </h2>\n        ) : null}\n        {description ? (\n          <p className=\"text-pretty text-sm leading-6 text-muted-foreground\">\n            {description}\n          </p>\n        ) : null}\n      </div>\n    ) : null;\n\n  const grid = (\n    <div className={cn(\"gap-3\", GRID_COLUMNS[maxLanes - 1])}>\n      {testimonials.map((t) => (\n        <TestimonialCard key={t.id} testimonial={t} className=\"mb-3 break-inside-avoid\" />\n      ))}\n    </div>\n  );\n\n  return (\n    <section className={cn(\"@container w-full\", className)}>\n      {header}\n      {variant === \"grid\" ? (\n        grid\n      ) : !animated ? (\n        // Reduced motion: same viewport, static masonry that scrolls by hand.\n        <div\n          style={{ height }}\n          className={cn(\n            \"overflow-y-auto px-px [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n            FADE_MASK,\n          )}\n        >\n          <div className=\"py-6\">{grid}</div>\n        </div>\n      ) : (\n        <div style={{ height }} className={cn(\"relative overflow-hidden\", FADE_MASK)}>\n          {Array.from({ length: maxLanes }, (_, i) => i + 1).map((laneCount) => (\n            <div\n              key={laneCount}\n              className={cn(\n                \"h-full gap-3\",\n                SHOW_AT[laneCount - 1],\n                laneCount < maxLanes && HIDE_AT[laneCount],\n              )}\n            >\n              {distribute(testimonials, laneCount).map((lane, laneIndex) => (\n                <Marquee\n                  // biome-ignore lint/suspicious/noArrayIndexKey: lanes are positional slots.\n                  key={laneIndex}\n                  direction={laneIndex % 2 === 0 ? \"up\" : \"down\"}\n                  // Slightly different loop lengths keep the columns from\n                  // moving in lockstep.\n                  // Duration scales with the lane's length so a single\n                  // narrow column holding every card moves at the same pace.\n                  speed={\n                    (speed * Math.max(lane.length, 4) * (1 + laneIndex * 0.12)) /\n                    Math.max(4, Math.ceil(testimonials.length / maxLanes))\n                  }\n                  gap=\"0.75rem\"\n                  fade={false}\n                  pauseOnHover={pauseOnHover}\n                  className={cn(\n                    \"h-full min-w-0 flex-1 [&>div]:w-full [&>div]:items-stretch\",\n                    pauseOnHover &&\n                      \"[&:focus-within>div]:[animation-play-state:paused]\",\n                  )}\n                >\n                  {fillLane(lane).map(({ item, copy }) =>\n                    copy ? (\n                      // Padding copies only exist so a short lane still\n                      // covers the viewport; hide them from assistive tech.\n                      <div key={`${item.id}-${copy}`} aria-hidden>\n                        <TestimonialCard testimonial={item} />\n                      </div>\n                    ) : (\n                      <TestimonialCard key={item.id} testimonial={item} />\n                    ),\n                  )}\n                </Marquee>\n              ))}\n            </div>\n          ))}\n        </div>\n      )}\n    </section>\n  );\n}\n"},{"path":"components/motion/marquee.tsx","type":"registry:component","target":"@components/motion/marquee.tsx","content":"import { Children, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MarqueeProps {\n  children: ReactNode;\n  direction?: \"left\" | \"right\" | \"up\" | \"down\";\n  speed?: number;\n  pauseOnHover?: boolean;\n  gap?: string;\n  className?: string;\n  fade?: boolean;\n}\n\nexport function Marquee({\n  children,\n  direction = \"left\",\n  speed = 30,\n  pauseOnHover = true,\n  gap = \"1rem\",\n  className,\n  fade = true,\n}: MarqueeProps) {\n  const vertical = direction === \"up\" || direction === \"down\";\n  const reverse = direction === \"right\" || direction === \"down\";\n  const items = Children.toArray(children);\n\n  return (\n    <div\n      className={cn(\n        \"group relative flex overflow-hidden\",\n        vertical ? \"flex-col\" : \"flex-row\",\n        fade && !vertical && \"[mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]\",\n        fade && vertical && \"[mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]\",\n        className,\n      )}\n      // gap on the wrapper too, so the seam between the two tracks matches the\n      // spacing between items and the loop stays even.\n      style={{ \"--gap\": gap, gap } as React.CSSProperties}\n    >\n      {[0, 1].map((dup) => (\n        <div\n          key={dup}\n          aria-hidden={dup === 1}\n          inert={dup === 1}\n          style={{\n            animationDuration: `${speed}s`,\n            animationDirection: reverse ? \"reverse\" : \"normal\",\n            gap,\n          }}\n          className={cn(\n            \"flex shrink-0 items-center\",\n            vertical ? \"flex-col animate-marquee-vertical\" : \"flex-row animate-marquee\",\n            pauseOnHover && \"group-hover:[animation-play-state:paused]\",\n          )}\n        >\n          {items.map((child, i) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: Marquee duplicates static child slots; item order is not mutated.\n            <div key={i} className=\"shrink-0\">\n              {child}\n            </div>\n          ))}\n        </div>\n      ))}\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}