Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • How it works
  • License
  • MCP

Page Transition (04)

  • Clip Transition
  • Stripe Transition
  • Pixel Transition
  • Band Transition

Components (36)

  • Orbit Gallery
  • Sphere Gallery
  • Spiral Gallery
  • Glowing Fog
  • Gradient Flow
  • Halftone Glow
  • Scattered Grid
  • Tag Cloud
  • Edge Bounce
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Dither Cursor
  • Hover Burst
  • Image Bloom
  • Curve Media
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered Scroll
  • Elastic Stick
  • Letter Swarm
  • Magnify Trail
  • Stacking Grid
  • Wavy Scroll
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Scramble
  • Falling Text
  • Text Roll

Foundation Blocks (07)

  • Smooth Scroll
  • Text Split
  • WebGL Image
  • WebGL Provider
  • WebGL Scene
  • WebGL Text
  • WebGL Video
Atelier UI 1.0.0 ©2026
Star on githubBuy me a coffeellms.txt
  1. Docs
  2. /
  3. Components
  4. /
  5. Infinite Gallery

Infinite Gallery

Zero-dependency infinite horizontal gallery with drag/wheel input, and a 3D flip effect.

https://atelier-ui.com/infinite-gallery
speed
6
inertia
0.6
drag-multiplier
3.0
speed
6
inertia
0.6
drag-multiplier
3.0
Prompt
Add Atelier's Infinite Gallery to my app.

If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/infinite-gallery
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.

Props: speed={6} inertia={0.6} dragMultiplier={3}

Just into your favorite coding agent and let Atelier do its magic. Live props are included.


This command will install all the dependencies this component uses.

npx shadcn@latest add @atelier/infinite-gallery

Install the dependencies first, then feel free to copy the files into your project as you see fit.

infinite-gallery.tsx
"use client"

import {
    Children,
    type ComponentRef,
    isValidElement,
    type ReactNode,
    useCallback,
    useEffect,
    useRef,
} from "react"
import { useFrameLoop } from "../../hooks/use-frame-loop"

const DEFAULT_SCROLL_STATE = {
    position: 0,
    target: 0,
    momentum: 0,
    prev: 0,
}

export type InfiniteGalleryProps = {
    children: ReactNode
    perView?: number
    gap?: number
    speed?: number
    inertia?: number
    dragMultiplier?: number
    className?: string
}

function lerp(start: number, end: number, factor: number) {
    return start + (end - start) * factor
}

function clamp(min: number, max: number, value: number) {
    return Math.min(max, Math.max(min, value))
}

export function InfiniteGallery({
    children,
    perView = 4,
    gap = 5,
    className,
    speed = 6,
    inertia = 0.6,
    dragMultiplier = 3,
}: InfiniteGalleryProps) {
    const sourceItems = Children.toArray(children).filter(isValidElement)
    const repeats = sourceItems.length ? Math.ceil((perView + 2) / sourceItems.length) : 0
    const items = Array.from({ length: repeats }, () => sourceItems).flat()

    const containerRef = useRef<ComponentRef<"div">>(null)
    const measureRef = useRef({ containerWidth: 0, itemWidth: 0 })
    const scrollRef = useRef({ ...DEFAULT_SCROLL_STATE }).current

    const calcItemsPositions = useCallback(
        (scrollOffset: number, velocity: number) => {
            const container = containerRef.current
            const { containerWidth, itemWidth } = measureRef.current

            if (!container) return

            const totalWidth = items.length * itemWidth

            for (let i = 0; i < items.length; i++) {
                const itemElement = container.children[i] as HTMLElement
                if (!itemElement) return

                const totalOffset = i * itemWidth + scrollOffset

                let xPos = ((totalOffset % totalWidth) + totalWidth) % totalWidth

                if (xPos > totalWidth - itemWidth) xPos -= totalWidth

                const itemInView = xPos > -itemWidth && xPos < containerWidth

                if (itemInView) {
                    const maxDeg = 85
                    const rotate = itemWidth
                        ? clamp(-maxDeg, maxDeg, (velocity / itemWidth) * 6)
                        : 0
                    const transform = `translate3d(${xPos - i * itemWidth}px, 0, 0) perspective(800px) rotateY(${rotate}deg)`

                    itemElement.style.transform = transform
                    itemElement.style.visibility = "visible"
                } else {
                    itemElement.style.visibility = "hidden"
                }
            }
        },
        [items],
    )

    const measure = useCallback(() => {
        const container = containerRef.current
        if (!container) return
        if (!container.children.length) return

        const firstChild = container.children[0] as HTMLElement
        const computedGap = parseFloat(getComputedStyle(container).gap) || 0

        measureRef.current = {
            containerWidth: container.getBoundingClientRect().width,
            itemWidth: firstChild.offsetWidth + computedGap,
        }
    }, [])

    useFrameLoop((_, delta) => {
        scrollRef.target += scrollRef.momentum
        scrollRef.momentum *= inertia

        const pos = scrollRef.position
        const target = scrollRef.target

        if (Math.abs(target - pos) > 0.01 || Math.abs(scrollRef.momentum) > 0.01) {
            const factor = 1 - Math.exp(-speed * delta)
            const next = lerp(pos, target, factor)
            scrollRef.position = next

            const frameDelta = (next - scrollRef.prev) * 60
            scrollRef.prev = next

            calcItemsPositions(next, frameDelta)
        }
    })

    useEffect(() => {
        const container = containerRef.current
        if (!container) return

        const resetScroll = () => {
            Object.assign(scrollRef, DEFAULT_SCROLL_STATE)
            measure()
            calcItemsPositions(0, 0)
        }

        resetScroll()

        const onWheel = (event: WheelEvent) => {
            event.preventDefault()
            scrollRef.momentum -= event.deltaY
        }

        const dragState = {
            startX: 0,
            isDragging: false,
            start: 0,
        }

        const onPointerDown = (event: PointerEvent) => {
            dragState.isDragging = true
            dragState.startX = event.clientX
            dragState.start = scrollRef.target
            scrollRef.momentum = 0
            container.setPointerCapture(event.pointerId)
        }

        const onPointerMove = (event: PointerEvent) => {
            if (!dragState.isDragging) return
            const delta = (event.clientX - dragState.startX) * dragMultiplier
            scrollRef.target = dragState.start + delta
        }

        const onPointerUp = () => {
            dragState.isDragging = false
        }

        const resizeObserver = new ResizeObserver(resetScroll)
        resizeObserver.observe(container)

        container.addEventListener("pointerdown", onPointerDown)
        container.addEventListener("pointermove", onPointerMove)
        container.addEventListener("pointerup", onPointerUp)
        container.addEventListener("wheel", onWheel, { passive: false })

        return () => {
            container.removeEventListener("pointerdown", onPointerDown)
            container.removeEventListener("pointermove", onPointerMove)
            container.removeEventListener("pointerup", onPointerUp)
            container.removeEventListener("wheel", onWheel)
            resizeObserver.disconnect()
        }
    }, [calcItemsPositions, perView, scrollRef, inertia, speed, dragMultiplier, measure])

    return (
        <div
            ref={containerRef}
            data-lenis-prevent
            style={{ gap: `${gap}px` }}
            className={`overflow-hidden touch-pan-y user-select-none ${className}`}
        >
            {items.map((item, index) => (
                <div
                    key={index}
                    className="shrink-0 will-change-transform"
                    style={{ width: `calc(${100 / perView}%)` }}
                >
                    {item}
                </div>
            ))}
        </div>
    )
}
use-frame-loop.ts
import { useEffect, useRef } from "react"

const DELTA_MAX = 0.1

type FrameLoopCallback = (time: number, delta: number) => void

export function useFrameLoop(callback: FrameLoopCallback, interval?: number) {
    const ref = useRef(callback)
    ref.current = callback

    useEffect(() => {
        let frameId = 0
        let lastTime = 0
        let lastTick = 0

        const tick = (now: number) => {
            frameId = requestAnimationFrame(tick)

            if (interval && now - lastTick < interval) return
            if (interval) lastTick = now

            const time = now * 0.001
            const delta = lastTime ? Math.min(time - lastTime, DELTA_MAX) : 0
            lastTime = time

            ref.current(time, delta)
        }

        frameId = requestAnimationFrame(tick)

        return () => {
            cancelAnimationFrame(frameId)
        }
    }, [interval])
}

Pass the items as children. Drag or scroll to move the gallery.

const IMAGES = [
    "https://picsum.photos/seed/atelier-1/1200/800",
    "https://picsum.photos/seed/atelier-2/1200/800",
]

<InfiniteGallery className="flex py-15" perView={4}>
    {IMAGES.map((src) => (
        <img key={src} src={src} alt="" className="h-80 w-auto object-cover" />
    ))}
</InfiniteGallery>

NameTypeDefaultDescription
childrenReactNode—Items to render in the gallery. Repeated automatically to fill the view. Required.
perViewnumber4Number of items visible at once.
speednumber6Speed of the scroll.
inertianumber0.6Inertia of the scroll.
dragMultipliernumber3Multiplier applied to drag distance.
gapnumber5Gap between items.
classNamestring—Class for the container element.

No dependencies. This one runs on React and Tailwind CSS alone.

Star on githubBuy me a coffeellms.txt