Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (28)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Scattered Scroll
  • Text Split
  • WebGL Image
  • 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 transform modes.

JS Animation
Tailwind CSS
https://atelier-ui.com/infinite-gallery

Settings

speed
6
inertia
0.6
drag-multiplier
3.0
See the documentation below for more options.

Install

npx atelier-ui add infinite-gallery
infinite-gallery.tsx
import { type ComponentRef, 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 InfiniteGalleryMode = "shrink" | "flip"

export type InfiniteGalleryProps<T> = {
    mode?: InfiniteGalleryMode
    data: T[]
    perView?: number
    gap?: number
    speed?: number
    inertia?: number
    dragMultiplier?: number
    className?: string
    renderItem: (item: T) => React.ReactNode
}

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<T>({
    mode = "flip",
    renderItem,
    perView = 4,
    gap = 5,
    data,
    className,
    speed = 6,
    inertia = 0.6,
    dragMultiplier = 3,
}: InfiniteGalleryProps<T>) {
    const containerRef = useRef<ComponentRef<"div">>(null)
    const measureRef = useRef({ containerWidth: 0, itemWidth: 0, offsetWidth: 0 })
    const scrollRef = useRef({ ...DEFAULT_SCROLL_STATE }).current

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

            if (!container) return

            const totalWidth = data.length * itemWidth

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

                const totalOffset = i * itemWidth + scrollOffset

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

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

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

                if (itemInView) {
                    let transform = `translate3d(${x - i * itemWidth}px, 0, 0)`

                    if (mode === "shrink") {
                        if (x < 0) {
                            const scaleX = (x + offsetWidth) / offsetWidth
                            containerEl.style.transformOrigin = "right"
                            transform += ` scaleX(${clamp(0, 1, scaleX)})`
                        } else if (x + itemWidth > containerWidth) {
                            const scaleX = (containerWidth - x) / offsetWidth
                            containerEl.style.transformOrigin = "left"
                            transform += ` scaleX(${clamp(0, 1, scaleX)})`
                        } else {
                            containerEl.style.transformOrigin = "center"
                        }
                    } else if (mode === "flip") {
                        const maxDeg = 85
                        const rotate = clamp(-maxDeg, maxDeg, velocity * 0.02)
                        transform += ` perspective(800px) rotateY(${rotate}deg)`
                    }

                    containerEl.style.transform = transform
                    containerEl.style.visibility = "visible"
                } else {
                    containerEl.style.visibility = "hidden"
                }
            }
        },
        [data, mode],
    )

    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,
            offsetWidth: firstChild.offsetWidth,
        }
    }, [])

    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}
            style={{ gap: `${gap}px` }}
            className={`overflow-hidden touch-pan-y user-select-none flex ${className}`}
        >
            {data.map((item, index) => {
                return (
                    <div
                        key={index}
                        className="shrink-0 will-change-transform"
                        style={{ width: `calc(${100 / perView}%)` }}
                    >
                        {renderItem(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])
}

API

NameTypeDefaultDescription
dataT[]—Array of items to render.
renderItem(item: T) => ReactNode—Render function called for each item.
mode"shrink" | "flip""flip"flip tilts items in 3D. shrink compresses edges.
perViewnumber4Number of items visible at once.
speednumber6Speed of the scroll.
inertianumber0.6Inertia of the scroll.
dragMultipliernumber3Multiplier applied to drag distance.
gapnumber5Gap between items.
classNamestringundefinedClass for the container element.

Credits

The Lookback
Infinite gallery effect inspired by the website of Better Off® Studio.

  • Install
  • API
  • Credits
Star on githubBuy me a coffeellms.txt