Zero-dependency infinite horizontal gallery with drag/wheel input, and a 3D flip effect.
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-galleryInstall the dependencies first, then feel free to copy the files into your project as you see fit.
"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>
)
}
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>| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Items to render in the gallery. Repeated automatically to fill the view. Required. |
perView | number | 4 | Number of items visible at once. |
speed | number | 6 | Speed of the scroll. |
inertia | number | 0.6 | Inertia of the scroll. |
dragMultiplier | number | 3 | Multiplier applied to drag distance. |
gap | number | 5 | Gap between items. |
className | string | — | Class for the container element. |
No dependencies. This one runs on React and Tailwind CSS alone.