Mouse wheel infinite zoom effect.
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-zoomInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install motion"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>| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Items to zoom. Required. |
zoomAmount | number | 3 | Scale between items. Higher values equals increased depth. |
lerpValue | number | 0.08 | Smoothing for the wheel input. |
className | string | — | Class for the outer container. Use it to size the zoom and block native scrolling, e.g. fixed inset-0 overflow-hidden touch-none. |
backgroundSpeed | number | 0.2 | Background speed. Deceleration of the last item in view. |
Motion
React animation library.