{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-wall",
  "type": "registry:ui",
  "title": "Testimonial Wall",
  "description": "Masonry of quotes drifting in opposite columns that ease to a stop under the pointer.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/testimonial-wall.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring, swap } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type Testimonial = {\n  /** Stable key. Falls back to the name. */\n  id?: string;\n  /** The quote. Wrap the line that sells it in <strong> and it steps up to the primary color. */\n  quote: React.ReactNode;\n  name: string;\n  /** Role and company, e.g. \"Staff engineer, Northwind\". */\n  role?: string;\n  /** Square image URL. Initials show until it loads, and if it never does. */\n  avatar?: string;\n};\n\n/** Cards and columns share one gap, so the loop's seam is invisible. */\nconst GAP = 12;\n/** Narrowest a column gets before the wall drops one. */\nconst MIN_COLUMN = 216;\n/** Each column runs at a slightly different pace, so the wall never moves in lockstep. */\nconst PACE = [1, 0.84, 1.12, 0.93];\n/** How long the wall takes to settle when it slows for a reader, and to pick up again. */\nconst SLOW_MS = 170;\nconst RESUME_MS = 360;\n\n// Reduced motion changes the markup (a scrollable region instead of a loop), so it\n// reads \"no preference\" while hydrating and switches to the real value right after.\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nfunction subscribeReduced(onChange: () => void) {\n  const mq = window.matchMedia(reducedQuery);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n}\n\n/**\n * Deals items into columns, each to the currently shortest one, estimating height\n * from the quote's length. Deterministic, so server and client agree.\n */\nfunction distribute(items: Testimonial[], count: number) {\n  const cols: { item: Testimonial; index: number }[][] = Array.from({ length: count }, () => []);\n  const heights = new Array<number>(count).fill(0);\n  items.forEach((item, index) => {\n    let target = 0;\n    for (let c = 1; c < count; c++) if (heights[c] < heights[target] - 1) target = c;\n    cols[target].push({ item, index });\n    heights[target] += 96 + (typeof item.quote === \"string\" ? item.quote.length : 140) * 0.55;\n  });\n  return cols;\n}\n\nexport type TestimonialWallProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  items: Testimonial[];\n  /** The most columns it will use. It drops to fewer as its container narrows (about 216px each). */\n  columns?: 1 | 2 | 3 | 4;\n  /** Pixels per second for the average column. */\n  speed?: number;\n  /** Height of the visible window. */\n  height?: number | string;\n  /** Whether the columns run. Hover and touch-and-hold still pause them for a moment. */\n  playing?: boolean;\n  defaultPlaying?: boolean;\n  onPlayingChange?: (playing: boolean) => void;\n  /** Show the pause button in the corner. Motion longer than five seconds needs one; hide it only if you render your own. */\n  showControl?: boolean;\n  pauseLabel?: string;\n  playLabel?: string;\n  /** Draw your own card. Defaults to TestimonialCard. */\n  renderItem?: (item: Testimonial, index: number) => React.ReactNode;\n};\n\n/**\n * A masonry of testimonials in columns that drift in opposite directions, looping\n * without a seam. A pointer resting on it slows every column to a stop so the card\n * under it can be read, and they ease back up when it leaves. Off screen, in a\n * hidden tab or under reduced motion nothing moves; with reduced motion the wall\n * becomes a plain scrollable region instead.\n */\nexport function TestimonialWall({\n  items,\n  columns = 3,\n  speed = 18,\n  height = 480,\n  playing: playingProp,\n  defaultPlaying = true,\n  onPlayingChange,\n  showControl = true,\n  pauseLabel = \"Pause testimonials\",\n  playLabel = \"Play testimonials\",\n  renderItem = (item) => <TestimonialCard {...item} />,\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"Testimonials\",\n  ...rest\n}: TestimonialWallProps) {\n  const [playing, setPlaying] = useControllableState({ value: playingProp, defaultValue: defaultPlaying, onChange: onPlayingChange });\n  const reduce = usePrefersReducedMotion();\n  const [section, setSection] = useState<HTMLElement | null>(null);\n  const [fit, setFit] = useState<number>(columns);\n  const [hovered, setHovered] = useState(false);\n  const [holding, setHolding] = useState(false);\n  const [visible, setVisible] = useState(true);\n  const [pageHidden, setPageHidden] = useState(false);\n\n  // How many columns fit, and whether anyone can see the wall at all.\n  useEffect(() => {\n    if (!section) return;\n    const ro = new ResizeObserver(([e]) => setFit(Math.max(1, Math.floor((e.contentRect.width + GAP) / (MIN_COLUMN + GAP)))));\n    ro.observe(section);\n    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));\n    io.observe(section);\n    const onVis = () => setPageHidden(document.hidden);\n    document.addEventListener(\"visibilitychange\", onVis);\n    return () => {\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [section]);\n\n  const count = Math.max(1, Math.min(columns, fit, items.length));\n  const cols = useMemo(() => distribute(items, count), [items, count]);\n\n  const animating = !reduce;\n  const moving = animating && playing && !hovered && !holding && visible && !pageHidden;\n  // Leaving the screen or the tab stops dead; a reader arriving gets the ease.\n  const instant = !visible || pageHidden;\n\n  // One rate for every column, eased toward 0 or 1 on its own frame loop. Setting\n  // playbackRate keeps each animation's current time, so nothing ever jumps.\n  const anims = useRef(new Set<Animation>());\n  const rate = useRef(0);\n  const register = useCallback((anim: Animation) => {\n    anim.playbackRate = rate.current;\n    anims.current.add(anim);\n    return () => void anims.current.delete(anim);\n  }, []);\n\n  useEffect(() => {\n    if (!animating) return;\n    const target = moving ? 1 : 0;\n    const apply = () => anims.current.forEach((a) => (a.playbackRate = rate.current));\n    if (instant) {\n      rate.current = target;\n      apply();\n      return;\n    }\n    const tau = target ? RESUME_MS / 3 : SLOW_MS / 3;\n    let raf = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      rate.current += (target - rate.current) * (1 - Math.exp(-(now - last) / tau));\n      last = now;\n      if (Math.abs(target - rate.current) < 0.004) rate.current = target;\n      apply();\n      if (rate.current !== target) raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [animating, moving, instant]);\n\n  if (!items.length) return null;\n\n  return (\n    <section\n      ref={setSection}\n      aria-label={ariaLabel}\n      data-slot=\"testimonial-wall\"\n      data-state={reduce ? \"static\" : moving ? \"playing\" : \"paused\"}\n      data-columns={count}\n      className={cn(\"group/wall @container/wall relative isolate min-w-0\", className)}\n      style={style}\n      {...rest}\n    >\n      <div\n        tabIndex={reduce ? 0 : undefined}\n        role={reduce ? \"region\" : undefined}\n        aria-label={reduce ? `${ariaLabel}, scrollable` : undefined}\n        onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(true)}\n        onPointerLeave={() => setHovered(false)}\n        // On touch, a finger resting on the wall holds it still; a scroll cancels the hold.\n        onPointerDown={(e) => e.pointerType !== \"mouse\" && setHolding(true)}\n        onPointerUp={() => setHolding(false)}\n        onPointerCancel={() => setHolding(false)}\n        className={cn(\n          // Bleeds 12px each side so card shadows are not clipped by the overflow.\n          \"-mx-3 flex min-w-0 gap-3 px-3 outline-none\",\n          reduce\n            ? \"peer overflow-y-auto overscroll-contain [mask-image:linear-gradient(to_bottom,var(--fg)_calc(100%-40px),transparent)]\"\n            : \"overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,var(--fg)_14%,var(--fg)_86%,transparent)]\",\n        )}\n        style={{ height }}\n      >\n        {cols.map((col, i) => (\n          <Column\n            key={i}\n            entries={col}\n            index={i}\n            reverse={i % 2 === 1}\n            speed={speed * PACE[i % PACE.length]}\n            animate={animating}\n            register={register}\n            renderItem={renderItem}\n          />\n        ))}\n      </div>\n\n      {/* The mask would clip an outline drawn on the scroller itself, so the ring is a sibling. */}\n      {reduce && (\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 hidden rounded-xl outline-1 outline-offset-2 outline-fg-3 peer-focus-visible:block peer-focus-visible:outline-solid\"\n        />\n      )}\n\n      {showControl && !reduce && (\n        <PlayToggle playing={playing} onToggle={() => setPlaying(!playing)} pauseLabel={pauseLabel} playLabel={playLabel} />\n      )}\n    </section>\n  );\n}\n\ntype Entry = { item: Testimonial; index: number };\n\nfunction Column({\n  entries,\n  index,\n  reverse,\n  speed,\n  animate,\n  register,\n  renderItem,\n}: {\n  entries: Entry[];\n  index: number;\n  reverse: boolean;\n  speed: number;\n  animate: boolean;\n  register: (anim: Animation) => () => void;\n  renderItem: (item: Testimonial, index: number) => React.ReactNode;\n}) {\n  const viewport = useRef<HTMLDivElement>(null);\n  const track = useRef<HTMLDivElement>(null);\n  const first = useRef<HTMLUListElement>(null);\n  // Copies needed so the column never shows a gap: enough to cover the window, plus one.\n  const [copies, setCopies] = useState(2);\n\n  useEffect(() => {\n    const view = viewport.current;\n    const el = track.current;\n    const one = first.current;\n    if (!animate || !view || !el || !one) return;\n    let anim: Animation | null = null;\n    let unregister = () => {};\n    let lastDistance = 0;\n    let lastDuration = 0;\n\n    const build = () => {\n      // One copy plus the gap to the next: moving by exactly this lands on an identical frame.\n      const distance = one.offsetHeight + GAP;\n      setCopies(Math.max(2, Math.ceil(view.clientHeight / distance) + 1));\n      if (distance === lastDistance && anim) return;\n      lastDistance = distance;\n      const duration = (distance / speed) * 1000;\n      // Keep the place in the loop across rebuilds. A fresh reverse column starts at the\n      // end of its loop, which is the same frame the server rendered.\n      const progress = anim && lastDuration ? (Number(anim.currentTime) % lastDuration) / lastDuration : reverse ? 0.9999 : 0;\n      lastDuration = duration;\n      unregister();\n      anim?.cancel();\n      anim = el.animate(\n        [{ transform: `translate3d(0, ${reverse ? -distance : 0}px, 0)` }, { transform: `translate3d(0, ${reverse ? 0 : -distance}px, 0)` }],\n        { duration, iterations: Infinity, easing: \"linear\" },\n      );\n      anim.currentTime = progress * duration;\n      unregister = register(anim);\n    };\n\n    const ro = new ResizeObserver(build);\n    ro.observe(one);\n    ro.observe(view);\n    return () => {\n      ro.disconnect();\n      unregister();\n      anim?.cancel();\n    };\n  }, [animate, reverse, speed, register]);\n\n  return (\n    <div\n      ref={viewport}\n      data-column={index}\n      className={cn(\n        \"min-w-0 flex-1\",\n        // Before hydration measures the width, container queries hide the columns that won't fit.\n        index === 1 && \"@max-[443px]/wall:hidden\",\n        index === 2 && \"@max-[671px]/wall:hidden\",\n        index === 3 && \"@max-[899px]/wall:hidden\",\n      )}\n    >\n      <div ref={track} className=\"flex flex-col gap-3 will-change-transform\">\n        <Copy ref={first} entries={entries} renderItem={renderItem} />\n        {animate &&\n          Array.from({ length: copies - 1 }, (_, c) => <Copy key={c} entries={entries} renderItem={renderItem} duplicate />)}\n      </div>\n    </div>\n  );\n}\n\nfunction Copy({\n  entries,\n  renderItem,\n  duplicate,\n  ref,\n}: {\n  entries: Entry[];\n  renderItem: (item: Testimonial, index: number) => React.ReactNode;\n  duplicate?: boolean;\n  ref?: React.Ref<HTMLUListElement>;\n}) {\n  return (\n    // The loop's repeats are for the eye only: hidden from assistive tech and unfocusable.\n    <ul ref={ref} aria-hidden={duplicate || undefined} inert={duplicate || undefined} className=\"flex flex-col gap-3\">\n      {entries.map(({ item, index }) => (\n        <li key={item.id ?? `${item.name}-${index}`}>{renderItem(item, index)}</li>\n      ))}\n    </ul>\n  );\n}\n\nexport type TestimonialCardProps = Omit<React.ComponentProps<\"figure\">, \"children\"> & Omit<Testimonial, \"id\">;\n\n/** One quote with who said it. Name and role wrap rather than truncate: the company is the point. */\nexport function TestimonialCard({ quote, name, role, avatar, className, ...rest }: TestimonialCardProps) {\n  return (\n    <figure\n      data-slot=\"testimonial-card\"\n      className={cn(\"flex flex-col gap-3.5 rounded-xl border border-line bg-raised p-4 shadow-[var(--shadow)]\", className)}\n      {...rest}\n    >\n      <blockquote className=\"text-[13px] leading-[1.55] text-pretty text-fg-2 [&_strong]:font-normal [&_strong]:text-fg\">\n        <p>{quote}</p>\n      </blockquote>\n      <figcaption className=\"flex min-w-0 items-center gap-2.5\">\n        <Avatar name={name} src={avatar} />\n        <span className=\"flex min-w-0 flex-col\">\n          <span className=\"text-[12.5px] font-medium leading-[1.3] tracking-[-0.005em] text-fg\">{name}</span>\n          {role && <span className=\"text-[12px] leading-[1.35] text-pretty text-fg-3\">{role}</span>}\n        </span>\n      </figcaption>\n    </figure>\n  );\n}\n\nfunction Avatar({ name, src }: { name: string; src?: string }) {\n  const [status, setStatus] = useState<\"loading\" | \"loaded\" | \"failed\">(\"loading\");\n  const initials = name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n  return (\n    <span aria-hidden className=\"relative grid size-7 shrink-0 place-items-center overflow-hidden rounded-full border border-line-2 bg-hover text-[10px] font-medium tracking-[0.02em] text-fg-2\">\n      {initials}\n      {src && status !== \"failed\" && (\n        // eslint-disable-next-line @next/next/no-img-element -- a copied component can't assume next/image\n        <img\n          src={src}\n          alt=\"\"\n          width={28}\n          height={28}\n          loading=\"lazy\"\n          decoding=\"async\"\n          onLoad={() => setStatus(\"loaded\")}\n          onError={() => setStatus(\"failed\")}\n          className={cn(\"absolute inset-0 size-full object-cover transition-opacity duration-200 ease-out\", status === \"loaded\" ? \"opacity-100\" : \"opacity-0\")}\n        />\n      )}\n    </span>\n  );\n}\n\nfunction PlayToggle({ playing, onToggle, pauseLabel, playLabel }: { playing: boolean; onToggle: () => void; pauseLabel: string; playLabel: string }) {\n  return (\n    <button\n      type=\"button\"\n      data-state={playing ? \"playing\" : \"paused\"}\n      aria-label={playing ? pauseLabel : playLabel}\n      onClick={onToggle}\n      className={cn(\n        \"absolute bottom-2 right-2 z-10 inline-grid size-7 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)]\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart hover:border-fg-4 hover:bg-hover hover:text-fg\",\n        \"active:scale-[0.92] active:duration-75\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent] pointer-coarse:before:absolute pointer-coarse:before:-inset-2 pointer-coarse:before:content-['']\",\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.svg\n          key={playing ? \"pause\" : \"play\"}\n          width={12}\n          height={12}\n          viewBox=\"0 0 16 16\"\n          fill=\"currentColor\"\n          aria-hidden\n          focusable={false}\n          initial={swap.initial}\n          animate={swap.animate}\n          exit={swap.exit}\n          transition={spring.pop}\n        >\n          {playing ? (\n            <>\n              <rect x=\"4\" y=\"3\" width=\"2.75\" height=\"10\" rx=\"1\" />\n              <rect x=\"9.25\" y=\"3\" width=\"2.75\" height=\"10\" rx=\"1\" />\n            </>\n          ) : (\n            // Nudged right: a triangle looks centered when its mass is, not its box.\n            <path d=\"M5.5 3.6v8.8a.8.8 0 0 0 1.2.7l7-4.4a.8.8 0 0 0 0-1.4l-7-4.4a.8.8 0 0 0-1.2.7z\" transform=\"translate(-0.6 0)\" />\n          )}\n        </motion.svg>\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/testimonial-wall"
}
