{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scroll-portrait-wall", "type": "registry:ui", "title": "Scroll Portrait Wall", "description": "A scroll-driven portrait wall with a sticky mix-blend-exclusion title; GSAP ScrollTrigger scrubs each portrait from scale 0 to 1 and back as its row crosses the viewport.", "dependencies": [ "gsap", "@gsap/react" ], "files": [ { "path": "registry/ruixenui/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(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(null);\n const hintRef = React.useRef(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(\".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 \n {/* Scroll hint, lower-centre of the first screen, fading on scroll */}\n \n \n {hint}\n \n \n\n {/* Sticky centred title — inverts against whatever portrait is behind it */}\n
\n

\n {title}\n

\n {date && (\n

\n {date}\n

\n )}\n
\n\n {/* The scattered portrait grid */}\n
\n {layout.map((row, ri) => (\n
\n {row.map((idx, ci) => {\n if (idx === -1)\n return
;\n\n const s = speakers[idx];\n const origin = ci < cols / 2 ? \"right bottom\" : \"left bottom\";\n\n return (\n
\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n {showCaptions && (\n
\n {s.name}\n ({s.role})\n
\n )}\n
\n
\n );\n })}\n
\n ))}\n
\n \n );\n}\n\nexport default ScrollPortraitWall;\n", "type": "registry:ui", "target": "components/ruixen/scroll-portrait-wall.tsx" } ] }