{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logo-wall-shuffle",
  "type": "registry:block",
  "title": "Logo Wall Shuffle",
  "description": "An integrations wall of staggered logo tiles where one mark at a time lifts away and the next rises in behind it. Every tile is visited before any repeats, and the loop pauses off-screen and in background tabs.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/logo-wall-shuffle.tsx",
      "type": "registry:component",
      "target": "components/tweenui/logo-wall-shuffle.tsx",
      "content": "'use client';\n\nimport {\n  useCallback,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** Seconds between swaps. */\nconst LOOP_DELAY = 1.65;\nconst DURATION = 0.6;\nconst TRAVEL = 40;\nconst BLUR = 'blur(4px)';\n\nconst CARD =\n  'flex items-center justify-center rounded-xl bg-white size-[76px] lg:size-[88px] dark:bg-[#161b22] [filter:drop-shadow(0_2px_5px_rgba(0,1,15,0.08))_drop-shadow(0_8px_8px_rgba(0,1,15,0.06))_drop-shadow(0_19px_11px_rgba(0,1,15,0.04))] dark:[filter:none]';\n\nconst shuffled = <T,>(input: T[]) => {\n  const copy = input.slice();\n  for (let i = copy.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1));\n    [copy[i], copy[j]] = [copy[j], copy[i]];\n  }\n  return copy;\n};\n\nexport interface LogoWallLogo {\n  /** Logo URL. */\n  src: string;\n  /** Swapped in under `.dark`. Falls back to `src`. */\n  srcDark?: string;\n  /** Alt text. */\n  alt: string;\n}\n\nexport interface LogoWallShuffleProps extends Omit<ComponentPropsWithoutRef<'section'>, 'title'> {\n  /** Section heading. */\n  title?: ReactNode;\n  /** Supporting line under the heading. */\n  description?: string;\n  /** Marks cycled through the wall. Needs more logos than tiles to keep moving. */\n  logos: LogoWallLogo[];\n  /** Tiles per column, left to right. Every other column drops down. */\n  columns?: number[];\n}\n\nconst DEFAULT_COLUMNS = [1, 2, 3, 2, 3, 2, 1];\n\nconst DEFAULT_TITLE = 'Your entire tech stack, perfectly connected';\n\nconst DEFAULT_DESCRIPTION =\n  'Seamlessly integrate with your favorite tools and bring all your workflows into one unified platform.';\n\nconst LogoMark = ({ logo, className }: { logo: LogoWallLogo; className?: string }) => {\n  return (\n    <>\n      <img\n        src={logo.src}\n        alt={logo.alt}\n        loading=\"lazy\"\n        className={cn('size-full object-contain', logo.srcDark && 'dark:hidden', className)}\n      />\n      {logo.srcDark && (\n        <img\n          src={logo.srcDark}\n          alt={logo.alt}\n          loading=\"lazy\"\n          className={cn('hidden size-full object-contain dark:block', className)}\n        />\n      )}\n    </>\n  );\n};\n\nconst LogoWallShuffle = ({\n  title = DEFAULT_TITLE,\n  description = DEFAULT_DESCRIPTION,\n  logos,\n  columns = DEFAULT_COLUMNS,\n  className,\n  ...props\n}: LogoWallShuffleProps) => {\n  const rootRef = useRef<HTMLElement>(null);\n  const tileCount = columns.reduce((total, size) => total + size, 0);\n\n  /**\n   * One logo index per tile, plus the mark being animated out. Swapping through\n   * state — rather than cloning DOM nodes as the original did — keeps React the\n   * only thing mutating this subtree.\n   */\n  const [tiles, setTiles] = useState<{ logo: number; leaving: number | null }[]>(() =>\n    Array.from({ length: tileCount }, (_, index) => ({\n      logo: index % Math.max(logos.length, 1),\n      leaving: null,\n    }))\n  );\n\n  const orderRef = useRef<number[]>([]);\n  const cursorRef = useRef(0);\n  const nextLogoRef = useRef(tileCount);\n\n  const settle = useCallback((tile: number) => {\n    setTiles((current) =>\n      current.map((entry, index) => (index === tile ? { ...entry, leaving: null } : entry))\n    );\n  }, []);\n\n  useGSAP(\n    () => {\n      if (prefersReducedMotion() || logos.length <= 1) return;\n\n      // Visit every tile once before repeating, so no card sits stale.\n      orderRef.current = shuffled(Array.from({ length: tileCount }, (_, i) => i));\n      cursorRef.current = 0;\n\n      const swap = () => {\n        const order = orderRef.current;\n        if (order.length === 0) return;\n\n        if (cursorRef.current >= order.length) {\n          orderRef.current = shuffled(order);\n          cursorRef.current = 0;\n        }\n        const tile = orderRef.current[cursorRef.current];\n        cursorRef.current += 1;\n\n        setTiles((current) => {\n          const incoming = nextLogoRef.current % logos.length;\n          nextLogoRef.current += 1;\n          if (current[tile]?.logo === incoming) return current;\n          return current.map((entry, index) =>\n            index === tile ? { logo: incoming, leaving: entry.logo } : entry\n          );\n        });\n      };\n\n      const loop = gsap.timeline({ repeat: -1, repeatDelay: LOOP_DELAY });\n      loop.call(swap).to({}, { duration: DURATION });\n\n      const trigger = ScrollTrigger.create({\n        trigger: rootRef.current,\n        start: 'top bottom',\n        end: 'bottom top',\n        onEnter: () => loop.play(),\n        onLeave: () => loop.pause(),\n        onEnterBack: () => loop.play(),\n        onLeaveBack: () => loop.pause(),\n      });\n\n      // Don't burn frames in a background tab.\n      const onVisibility = () => (document.hidden ? loop.pause() : loop.play());\n      document.addEventListener('visibilitychange', onVisibility);\n\n      return () => {\n        trigger.kill();\n        loop.kill();\n        document.removeEventListener('visibilitychange', onVisibility);\n      };\n    },\n    { scope: rootRef, dependencies: [tileCount, logos.length] }\n  );\n\n  const columnStarts = columns.map((_, columnIndex) =>\n    columns.slice(0, columnIndex).reduce((total, size) => total + size, 0)\n  );\n\n  return (\n    <section\n      ref={rootRef}\n      data-logo-wall-shuffle\n      className={cn('w-full px-5 py-16 md:py-20', className)}\n      {...props}\n    >\n      <div className=\"mx-auto max-w-[1064px] space-y-10 md:space-y-14\">\n        <div className=\"mx-auto max-w-[600px] space-y-3 text-center\">\n          <h2 className=\"text-3xl font-medium tracking-tight text-[#12161f] md:text-5xl dark:text-white\">\n            {title}\n          </h2>\n          <p className=\"mx-auto max-w-[450px] text-base text-[#18181b]/60 md:text-lg dark:text-white/60\">\n            {description}\n          </p>\n        </div>\n\n        <div className=\"grid grid-cols-3 justify-items-center gap-4 md:grid-cols-5 lg:flex lg:flex-wrap lg:items-center lg:justify-center\">\n          {columns.map((size, columnIndex) => (\n            <div\n              key={columnIndex}\n              className={cn(\n                'contents lg:flex lg:flex-col lg:items-center lg:gap-4',\n                columnIndex % 2 === 1 && 'lg:translate-y-10'\n              )}\n            >\n              {Array.from({ length: size }, (_, row) => {\n                const tileIndex = columnStarts[columnIndex] + row;\n                const tile = tiles[tileIndex];\n                const logo = logos[tile?.logo ?? 0];\n                const leaving = tile?.leaving != null ? logos[tile.leaving] : undefined;\n\n                return (\n                  <div key={tileIndex} className={CARD}>\n                    <figure\n                      data-logo-wall-tile\n                      className=\"relative isolate size-10 motion-reduce:transform-none md:size-12\"\n                    >\n                      {leaving && (\n                        <SwapLayer\n                          role=\"leaving\"\n                          onDone={() => settle(tileIndex)}\n                          className=\"absolute inset-0\"\n                        >\n                          <LogoMark logo={leaving} />\n                        </SwapLayer>\n                      )}\n                      <SwapLayer role={leaving ? 'entering' : 'idle'} className=\"absolute inset-0\">\n                        <LogoMark logo={logo} />\n                      </SwapLayer>\n                    </figure>\n                  </div>\n                );\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default LogoWallShuffle;\n\n/**\n * A single mark layer. `entering` rises into place from below, `leaving` lifts\n * away and blurs out, `idle` just sits there.\n */\nconst SwapLayer = ({\n  role,\n  onDone,\n  className,\n  children,\n}: {\n  role: 'idle' | 'entering' | 'leaving';\n  onDone?: () => void;\n  className?: string;\n  children: ReactNode;\n}) => {\n  const ref = useRef<HTMLSpanElement>(null);\n\n  useGSAP(\n    () => {\n      if (role === 'idle' || prefersReducedMotion()) return;\n\n      if (role === 'entering') {\n        gsap.fromTo(\n          ref.current,\n          { y: TRAVEL, opacity: 0, filter: BLUR },\n          { y: 0, opacity: 1, filter: 'blur(0px)', duration: DURATION, ease: 'power1.inOut' }\n        );\n        return;\n      }\n\n      gsap.to(ref.current, {\n        y: -TRAVEL,\n        opacity: 0,\n        filter: BLUR,\n        duration: DURATION,\n        ease: 'power1.inOut',\n        onComplete: onDone,\n      });\n    },\n    { dependencies: [role] }\n  );\n\n  return (\n    <span ref={ref} className={cn('block', className)}>\n      {children}\n    </span>\n  );\n};\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/logo-wall-shuffle\nGSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up."
}