{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-wall",
  "type": "registry:ui",
  "title": "Testimonial Wall",
  "description": "Columns of quotes drifting past each other at different speeds, faded at both ends.",
  "registryDependencies": [
    "utils",
    "use-in-viewport"
  ],
  "files": [
    {
      "path": "registry/loomui/testimonial-wall.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useInViewport } from \"@/registry/lib/use-in-viewport\"\n\nexport interface TestimonialWallProps extends React.ComponentProps<\"div\"> {\n  /** Columns at the widest breakpoint. Narrow screens drop the extras. */\n  columns?: number\n  /** Seconds for one pass of the first column. */\n  duration?: number\n  /** Per-column multipliers on `duration`. Falls back to a fixed drift. */\n  speeds?: number[]\n  /** Gap between cards and between columns, as a CSS length. */\n  gap?: string\n  /** Stop every column while the pointer is over the wall. */\n  pauseOnHover?: boolean\n  /** Render the wall static. */\n  paused?: boolean\n  /** Fade the top and bottom out. Pass a CSS length to size the fade. */\n  fade?: boolean | string\n}\n\n/**\n * Alpha sampled off a smoothstep curve. A two-stop gradient ramps linearly, and\n * the eye reads where that ramp meets full opacity as a hard line. Sampling the\n * curve rounds both ends off, so the fade runs out instead of stopping.\n */\nconst FADE_STOPS = [0, 0.0608, 0.216, 0.5, 0.784, 0.939, 1]\n\nfunction fadeMask(size: string) {\n  const last = FADE_STOPS.length - 1\n  const ratio = (index: number) => (index / last).toFixed(4)\n\n  const leading = FADE_STOPS.map(\n    (alpha, index) => `rgb(0 0 0 / ${alpha}) calc(${size} * ${ratio(index)})`\n  )\n  const trailing = FADE_STOPS.map(\n    (_, index) =>\n      `rgb(0 0 0 / ${FADE_STOPS[last - index]}) calc(100% - ${size} * ${ratio(\n        last - index\n      )})`\n  )\n\n  return `linear-gradient(to bottom, ${[...leading, ...trailing].join(\", \")})`\n}\n\n/** Columns past the first are dropped rather than squeezed on small screens. */\nconst VISIBILITY = [\"flex\", \"hidden sm:flex\", \"hidden lg:flex\"]\n\nexport function TestimonialWall({\n  children,\n  className,\n  columns = 3,\n  duration = 44,\n  speeds,\n  gap = \"1rem\",\n  pauseOnHover = true,\n  paused = false,\n  fade = true,\n  style,\n  ...props\n}: TestimonialWallProps) {\n  const items = React.Children.toArray(children)\n  // Dealt round robin, so a wall of uneven cards still balances out.\n  const lanes = Array.from({ length: Math.max(columns, 1) }, (_, lane) =>\n    items.filter((_, index) => index % Math.max(columns, 1) === lane)\n  )\n\n  const mask = fade ? fadeMask(fade === true ? \"14%\" : fade) : undefined\n\n  const root = React.useRef<HTMLDivElement>(null)\n  const onScreen = useInViewport(root)\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"testimonial-wall\"\n      className={cn(\"group flex h-[32rem] overflow-hidden\", className)}\n      style={{\n        gap,\n        WebkitMaskImage: mask,\n        maskImage: mask,\n        ...style,\n      }}\n      {...props}\n    >\n      {lanes.map((lane, index) => (\n        <div\n          key={index}\n          className={cn(\n            \"min-w-0 flex-1 flex-col\",\n            VISIBILITY[index] ?? \"hidden lg:flex\"\n          )}\n          style={\n            {\n              gap,\n              // The keyframes travel exactly one copy plus one gap, which is\n              // what makes the seam invisible.\n              \"--marquee-gap\": gap,\n              \"--marquee-duration\": `${\n                duration * (speeds?.[index] ?? 1 + (index % 3) * 0.24)\n              }s`,\n            } as React.CSSProperties\n          }\n        >\n          {[0, 1].map((copy) => (\n            <div\n              key={copy}\n              aria-hidden={copy > 0 ? \"true\" : undefined}\n              className={cn(\n                \"animate-marquee-vertical flex shrink-0 flex-col\",\n                !onScreen && \"[animation-play-state:paused]\",\n                // Every other column runs the other way, so neighbouring cards\n                // never travel together and the wall reads as depth.\n                index % 2 === 1 && \"[animation-direction:reverse]\",\n                paused && \"[animation-play-state:paused]\",\n                pauseOnHover && \"group-hover:[animation-play-state:paused]\",\n                // A column that stops dead leaves half a card cut off, so the\n                // whole wall is frozen at its start instead.\n                \"motion-reduce:animate-none\"\n              )}\n              style={{ gap }}\n            >\n              {lane}\n            </div>\n          ))}\n        </div>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-marquee-vertical": "marquee-vertical var(--marquee-duration, 40s) linear infinite"
    }
  },
  "css": {
    "@keyframes marquee-vertical": {
      "from": {
        "transform": "translateY(0)"
      },
      "to": {
        "transform": "translateY(calc(-100% - var(--marquee-gap, 1rem)))"
      }
    }
  }
}