{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-portrait-wall","type":"registry:component","title":"Scroll Portrait Wall","description":"A scroll-portrait-wall component.","dependencies":["gsap","@gsap/react"],"registryDependencies":[],"files":[{"path":"src/components/ui/scroll-portrait-wall.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { useGSAP } from \"@gsap/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nif (typeof window !== \"undefined\") {\n  gsap.registerPlugin(ScrollTrigger);\n}\n\nexport interface Speaker {\n  name: string;\n  role: string;\n  /** Image URL. Square / portrait crops look best. */\n  src: string;\n}\n\nexport interface ScrollPortraitWallProps {\n  /** Big sticky title rendered with `mix-blend-exclusion`. */\n  title?: React.ReactNode;\n  /** Small line under the title. */\n  date?: React.ReactNode;\n  /** Scroll hint that fades out as the wall comes into view. */\n  hint?: React.ReactNode;\n  /** People to scatter across the wall. Defaults to a built-in demo set. */\n  speakers?: Speaker[];\n  /** Columns on large screens (auto-reduced to 3 on `sm` and 2 on mobile). */\n  columns?: number;\n  /** Show the name / role caption under each portrait. Default `true`. */\n  showCaptions?: boolean;\n  className?: string;\n}\n\n/* Deterministic placement so SSR and client agree (no Math.random):\n * one portrait per row, with every third row holding a second one,\n * columns walked in a scattered pattern. Returns a grid of speaker\n * indices (or -1 for an empty cell). */\nfunction buildLayout(count: number, cols: number): number[][] {\n  const rows: number[][] = [];\n  let i = 0;\n  let r = 0;\n  while (i < count) {\n    const row = new Array<number>(cols).fill(-1);\n    const a = (r * 2 + (r % 2)) % cols;\n    row[a] = i++;\n    if (r % 3 === 0 && i < count) {\n      let b = (a + 2) % cols;\n      if (b === a) b = (a + 1) % cols;\n      row[b] = i++;\n    }\n    rows.push(row);\n    r++;\n  }\n  return rows;\n}\n\n/* Keep portraits a usable size: cap the desired column count on smaller\n * viewports. Starts from `desired` so the SSR markup matches the first\n * client render, then narrows after mount. */\nfunction useResponsiveColumns(desired: number): number {\n  const [cols, setCols] = React.useState(desired);\n\n  React.useEffect(() => {\n    const sm = window.matchMedia(\"(min-width: 640px)\");\n    const lg = window.matchMedia(\"(min-width: 1024px)\");\n    const update = () => {\n      if (lg.matches) setCols(desired);\n      else if (sm.matches) setCols(Math.min(desired, 3));\n      else setCols(Math.min(desired, 2));\n    };\n    update();\n    sm.addEventListener(\"change\", update);\n    lg.addEventListener(\"change\", update);\n    return () => {\n      sm.removeEventListener(\"change\", update);\n      lg.removeEventListener(\"change\", update);\n    };\n  }, [desired]);\n\n  return cols;\n}\n\nconst DEMO_SPEAKERS: Speaker[] = [\n  { name: \"Alex Johnson\", role: \"CEO & Founder\" },\n  { name: \"Sarah Chen\", role: \"CTO\" },\n  { name: \"Marcus Rivera\", role: \"Lead Designer\" },\n  { name: \"Emily Watson\", role: \"Product Manager\" },\n  { name: \"David Kim\", role: \"Senior Developer\" },\n  { name: \"Lisa Thompson\", role: \"Marketing Director\" },\n  { name: \"James Wilson\", role: \"UX Researcher\" },\n  { name: \"Rachel Green\", role: \"Data Scientist\" },\n  { name: \"Michael Brown\", role: \"DevOps Engineer\" },\n  { name: \"Anna Davis\", role: \"Content Strategist\" },\n].map((s, i) => ({\n  ...s,\n  // 5 avatars on the CDN, cycled across the speakers.\n  src: `https://pub-940ccf6255b54fa799a9b01050e6c227.r2.dev/avatar-images/avatar-${String((i % 5) + 1).padStart(2, \"0\")}.jpg`,\n}));\n\nexport function ScrollPortraitWall({\n  title = \"Speakers\",\n  date = \"Oct 22, 2025\",\n  hint = \"scroll down to see effect\",\n  speakers = DEMO_SPEAKERS,\n  columns = 4,\n  showCaptions = true,\n  className,\n}: ScrollPortraitWallProps) {\n  const root = React.useRef<HTMLElement | null>(null);\n  const hintRef = React.useRef<HTMLDivElement | null>(null);\n  const cols = useResponsiveColumns(Math.max(1, columns));\n  const layout = React.useMemo(\n    () => buildLayout(speakers.length, cols),\n    [speakers.length, cols],\n  );\n\n  useGSAP(\n    () => {\n      const reduce = window.matchMedia(\n        \"(prefers-reduced-motion: reduce)\",\n      ).matches;\n      const items = gsap.utils.toArray<HTMLElement>(\".spw-item\");\n\n      if (reduce) {\n        gsap.set(items, { scale: 1 });\n        return;\n      }\n\n      // Hint fades away over the first stretch of scrolling.\n      gsap.to(hintRef.current, {\n        autoAlpha: 0,\n        ease: \"none\",\n        scrollTrigger: {\n          trigger: root.current,\n          start: \"top top\",\n          end: \"+=40%\",\n          scrub: true,\n        },\n      });\n\n      // Each portrait scrubs scale 0 → 1 → 0 across its full pass through the\n      // viewport: it grows in from its transform-origin corner, peaks at\n      // centre, then shrinks away — \"comes and goes\".\n      items.forEach((el) => {\n        gsap\n          .timeline({\n            scrollTrigger: {\n              trigger: el,\n              start: \"top bottom\",\n              end: \"bottom top\",\n              scrub: true,\n            },\n          })\n          .fromTo(\n            el,\n            { scale: 0 },\n            { scale: 1, ease: \"power2.out\", duration: 0.5 },\n          )\n          .to(el, { scale: 0, ease: \"power2.in\", duration: 0.5 });\n      });\n    },\n    { scope: root, dependencies: [cols], revertOnUpdate: true },\n  );\n\n  return (\n    <section\n      ref={root}\n      aria-label={typeof title === \"string\" ? title : undefined}\n      className={cn(\"relative w-full bg-background text-foreground\", className)}\n    >\n      {/* Scroll hint, lower-centre of the first screen, fading on scroll */}\n      <div\n        ref={hintRef}\n        className=\"pointer-events-none absolute left-1/2 top-[60vh] grid -translate-x-1/2 content-start justify-items-center gap-6 text-center\"\n      >\n        <span className=\"relative max-w-[12ch] text-xs uppercase leading-tight text-muted-foreground after:absolute after:left-1/2 after:top-full after:h-16 after:w-px after:bg-gradient-to-b after:from-transparent after:to-muted-foreground/40 after:content-['']\">\n          {hint}\n        </span>\n      </div>\n\n      {/* Sticky centred title — inverts against whatever portrait is behind it */}\n      <div className=\"pointer-events-none sticky top-1/2 z-20 -translate-y-1/2 text-center text-white mix-blend-exclusion\">\n        <h2 className=\"text-5xl font-semibold tracking-tighter sm:text-7xl md:text-8xl lg:text-9xl\">\n          {title}\n        </h2>\n        {date && (\n          <p className=\"mt-1 text-xs uppercase tracking-wide text-white/60 sm:text-sm\">\n            {date}\n          </p>\n        )}\n      </div>\n\n      {/* The scattered portrait grid */}\n      <div className=\"relative z-0 mb-[50vh] mt-[50vh]\">\n        {layout.map((row, ri) => (\n          <div key={ri} className=\"flex w-full\">\n            {row.map((idx, ci) => {\n              if (idx === -1)\n                return <div key={ci} className=\"aspect-square flex-1\" />;\n\n              const s = speakers[idx];\n              const origin = ci < cols / 2 ? \"right bottom\" : \"left bottom\";\n\n              return (\n                <div key={ci} className=\"aspect-square flex-1\">\n                  <div\n                    className=\"spw-item relative h-full w-full\"\n                    style={{ transformOrigin: origin, transform: \"scale(0)\" }}\n                  >\n                    {/* eslint-disable-next-line @next/next/no-img-element */}\n                    <img\n                      src={s.src}\n                      alt={s.name}\n                      loading=\"lazy\"\n                      decoding=\"async\"\n                      draggable={false}\n                      className=\"h-full w-full object-cover grayscale contrast-[1.15] filter transition-transform duration-500 ease-in-out hover:scale-95\"\n                    />\n                    {showCaptions && (\n                      <div className=\"absolute -bottom-2 left-0 flex w-full translate-y-full justify-between gap-2 text-[11px] uppercase leading-tight text-muted-foreground sm:text-sm\">\n                        <span className=\"truncate\">{s.name}</span>\n                        <span className=\"shrink-0\">({s.role})</span>\n                      </div>\n                    )}\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        ))}\n      </div>\n    </section>\n  );\n}\n\nexport default ScrollPortraitWall;\n","type":"registry:component"}]}