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 Zoom

Infinite Zoom

Mouse wheel infinite zoom effect.

https://atelier-ui.com/infinite-zoom
zoom-amount
3
lerp-value
0.08
background-speed
0.2
zoom-amount
3
lerp-value
0.08
background-speed
0.2
Prompt
Add Atelier's Infinite Zoom to my app.

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

Props: zoomAmount={3} lerpValue={0.08} backgroundSpeed={0.2}

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-zoom

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

npm install motion
infinite-zoom.tsx
"use client"

import { wrap } from "motion"
import {
    type MotionValue,
    motion,
    useAnimationFrame,
    useMotionValue,
    useTransform,
} from "motion/react"

import {
    Children,
    type ComponentRef,
    isValidElement,
    type ReactElement,
    type ReactNode,
    useEffect,
    useRef,
} from "react"

function lerp(current: number, target: number, factor: number) {
    return current + (target - current) * factor
}

export type InfiniteZoomProps = {
    children: ReactNode
    zoomAmount?: number
    lerpValue?: number
    className?: string
    backgroundSpeed?: number
}

type ItemsProps = {
    children: ReactElement
    index: number
    itemsCount: number
    progress: MotionValue<number>
    isClone: boolean
} & Required<Pick<InfiniteZoomProps, "zoomAmount" | "backgroundSpeed">>

function Items({
    children,
    index,
    itemsCount,
    zoomAmount,
    progress,
    isClone,
    backgroundSpeed,
}: ItemsProps) {
    const offset = index / itemsCount
    const minScale = zoomAmount ** -(itemsCount - 1)
    const totalScale = zoomAmount ** itemsCount
    const wrapperRef = useRef<ComponentRef<"div">>(null)

    const scale = useTransform(() => {
        const position = wrap(0, 1, progress.get() + offset)
        const scaleValue = minScale * totalScale ** position

        /*
         * once the image fills reach the outer container,
         * decelerate the scaling.
         */
        const overflow = Math.max(0, scaleValue - 1)
        return scaleValue - overflow * (1 - backgroundSpeed)
    })

    const clipPath = useTransform(() => {
        const _scale = scale.get()
        const wrapper = wrapperRef.current
        const item = wrapper?.firstElementChild as HTMLElement | null
        if (!wrapper || !item || _scale <= 1) return "none"
        const x = Math.max(0, (wrapper.offsetWidth - item.offsetWidth / _scale) / 2)
        const y = Math.max(0, (wrapper.offsetHeight - item.offsetHeight / _scale) / 2)
        return `inset(${y}px ${x}px)`
    })

    const zIndex = useTransform(() => {
        const pos = wrap(0, 1, progress.get() + offset)
        return Math.floor((1 - pos) * 1000)
    })

    return (
        <motion.div
            ref={wrapperRef}
            aria-hidden={isClone || undefined}
            style={{ scale, zIndex, clipPath }}
            className="absolute inset-0 flex items-center justify-center"
        >
            {children}
        </motion.div>
    )
}

export default function InfiniteZoom({
    children,
    zoomAmount = 5,
    lerpValue = 0.08,
    className,
    backgroundSpeed = 0.2,
}: InfiniteZoomProps) {
    const items = Children.toArray(children).filter(isValidElement) as ReactElement[]
    const itemsCount = items.length * 2
    const progress = useMotionValue(0)
    const smoothProgress = useMotionValue(0)
    const containerRef = useRef<ComponentRef<"div">>(null)

    useAnimationFrame(() => {
        smoothProgress.set(lerp(smoothProgress.get(), progress.get(), lerpValue))
    })

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

        const handleWheel = (event: WheelEvent) => {
            event.preventDefault()
            progress.set(progress.get() + event.deltaY * 0.0001)
        }

        el.addEventListener("wheel", handleWheel, { passive: false })
        return () => el.removeEventListener("wheel", handleWheel)
    }, [progress])

    return (
        <motion.div
            ref={containerRef}
            className={className}
            onPan={(event, info) => {
                event.preventDefault()
                progress.set(progress.get() + info.delta.y * 0.001)
            }}
        >
            {Array.from({ length: itemsCount }, (_, index) => (
                <Items
                    key={index}
                    index={index}
                    itemsCount={itemsCount}
                    zoomAmount={zoomAmount}
                    progress={smoothProgress}
                    isClone={index >= items.length}
                    backgroundSpeed={backgroundSpeed}
                >
                    {items[index % items.length]}
                </Items>
            ))}
        </motion.div>
    )
}

Pass the items as children and scroll the wheel to zoom through them.

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

<InfiniteZoom className="fixed inset-0 overflow-hidden touch-none">
    {IMAGES.map((src) => (
        <img key={src} src={src} alt="" className="h-auto w-150 object-cover" />
    ))}
</InfiniteZoom>

NameTypeDefaultDescription
childrenReactNode—Items to zoom. Required.
zoomAmountnumber3Scale between items. Higher values equals increased depth.
lerpValuenumber0.08Smoothing for the wheel input.
classNamestring—Class for the outer container. Use it to size the zoom and block native scrolling, e.g. fixed inset-0 overflow-hidden touch-none.
backgroundSpeednumber0.2Background speed. Deceleration of the last item in view.

Motion
React animation library.

Star on githubBuy me a coffeellms.txt