Infinite Grid

PreviousNext

Infinite grid effect applied to a grid of images.

Docs
phucbmcomponent

Preview

Loading preview…
registry/phucbm/blocks/infinite-grid/infinite-grid.tsx
"use client"
import React, {useRef} from "react"
import gsap from "gsap"
import {useGSAP} from "@gsap/react"
import {Observer} from "gsap/Observer"

gsap.registerPlugin(Observer, useGSAP)

export interface InfiniteGridProps {
    /** Array of images. **/
    images: {
        url: string;
        title: string;
    }[]
}

export function InfiniteGrid({images}: InfiniteGridProps) {
    const scope = useRef<HTMLDivElement | null>(null)
    const containerScale = useRef<HTMLDivElement | null>(null)

    useGSAP(
        () => {
            const root = scope.current
            const containerScaleElement = containerScale.current
            if (!root || !containerScale) return

            const container = root.querySelector(".container1") as HTMLElement
            const wrapper = root.querySelector(".wrapper") as HTMLElement
            if (!container || !wrapper) return

            // === Wrap helpers
            const halfX = wrapper.clientWidth / 2
            const wrapX = gsap.utils.wrap(-halfX, 0)
            const halfY = wrapper.clientHeight / 2
            const wrapY = gsap.utils.wrap(-halfY, 0)

            const xTo = gsap.quickTo(wrapper, "x", {
                duration: 1.5,
                ease: "power4",
                modifiers: {x: gsap.utils.unitize(wrapX)},
            })
            const yTo = gsap.quickTo(wrapper, "y", {
                duration: 1.5,
                ease: "power4",
                modifiers: {y: gsap.utils.unitize(wrapY)},
            })

            const scaleToX = gsap.quickTo(container, "scaleX", {
                duration: 1.5,
                ease: "power4",
            })
            const scaleToY = gsap.quickTo(container, "scaleY", {
                duration: 1.5,
                ease: "power4",
            })

            let incrX = 0,
                incrY = 0
            let interactionTimeout: NodeJS.Timeout

            // === GSAP Observer
            const gsapObserver = Observer.create({
                target: root,
                type: "wheel,touch,pointer",
                preventDefault: true,
                onPress: () => {
                    containerScaleElement.style.scale = "1"
                },
                onRelease: () => {
                    containerScaleElement.style.scale = "1.1";
                },
                onChangeX: (self) => {
                    incrX += self.event.type === "wheel" ? -self.deltaX : self.deltaX * 2
                    xTo(incrX)

                    const rawScale = 1 - self.deltaX / 200
                    const safeScale = gsap.utils.clamp(0.8, 1.2, rawScale)
                    scaleToX(safeScale)

                    clearTimeout(interactionTimeout)
                    interactionTimeout = setTimeout(() => scaleToX(1), 66)
                },

                onChangeY: (self) => {
                    incrY += self.event.type === "wheel" ? -self.deltaY : self.deltaY * 2
                    yTo(incrY)

                    const rawScale = 1 - self.deltaY / 200
                    const safeScale = gsap.utils.clamp(0.8, 1.2, rawScale)
                    scaleToY(safeScale)

                    clearTimeout(interactionTimeout)
                    interactionTimeout = setTimeout(() => scaleToY(1), 66)
                },
            })

            // cleanup automatically handled by useGSAP
            return () => {
                gsapObserver.kill()
            }
        },
        {scope}
    )

    return (
        <div ref={scope}>
            <div className="h-screen w-full overflow-hidden">
                <div ref={containerScale} className="scale-[1.1] transition-transform duration-300">
                    <div className="container1 h-full">
                        <div className="wrapper grid grid-cols-2 w-max will-change-transform">
                            {[...Array(4)].map((_, i) => (
                                <div
                                    className="content pointer-events-none grid w-max grid-cols-5 gap-[5vw] p-[calc(5vw/2)] max-[900px]:gap-[20vw] max-[900px]:p-[calc(20vw/2)]"
                                    key={i}
                                    aria-hidden={i !== 0}
                                >
                                    {images.map(({url, title}, index) => (
                                        <div key={index}
                                             className="w-[50vw] md:w-[18vw] aspect-square select-none">
                                            <img src={url} alt={title}
                                                 className="w-full h-full object-cover"
                                                 loading="eager"/>
                                        </div>
                                    ))}
                                </div>
                            ))}
                        </div>
                    </div>
                </div>
            </div>
        </div>
    )
}

Installation

npx shadcn@latest add @phucbm/infinite-grid

Usage

import { InfiniteGrid } from "@/components/infinite-grid"
<InfiniteGrid />