Composable infinite parallax column effect with auto-scroll and repeating content.
Add Atelier's Infinite Parallax to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/infinite-parallax
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.
Props: autoScrollSpeed={0.02} parallaxAmount={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-parallaxInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install motion"use client"
import { motion, useAnimationFrame, useMotionValue, useScroll, useVelocity } from "motion/react"
import { type ComponentRef, useEffect, useRef, useState } from "react"
const REVERT_THRESHOLD = 50
const PARALLAX_SCALE = 0.0001
export type InfiniteParallaxProps = {
reversed?: boolean
autoScrollSpeed?: number
parallaxAmount?: number
children?: React.ReactNode
className?: string
}
export function InfiniteParallax({
reversed,
autoScrollSpeed = 0.02,
parallaxAmount = 2,
children,
className,
}: InfiniteParallaxProps) {
const offsetRef = useRef(0)
const contentHRef = useRef(0)
const directionRef = useRef<1 | -1>(-1)
const containerRef = useRef<ComponentRef<"div">>(null)
const measureRef = useRef<ComponentRef<"div">>(null)
const [clones, setClones] = useState(1)
const y = useMotionValue(0)
const { scrollY } = useScroll()
const scrollVelocity = useVelocity(scrollY)
useEffect(() => {
const measure = measureRef.current
const container = containerRef.current
if (!measure || !container) return
const calcClones = () => {
const contentH = measure.getBoundingClientRect().height
const containerH = container.getBoundingClientRect().height
if (contentH === 0) return
contentHRef.current = contentH
setClones(Math.ceil(containerH / contentH))
}
calcClones()
const ro = new ResizeObserver(calcClones)
ro.observe(measure)
ro.observe(container)
return () => ro.disconnect()
}, [])
useAnimationFrame((_, delta) => {
if (!contentHRef.current) return
const height = contentHRef.current
const velocity = scrollVelocity.get()
if (Math.abs(velocity) > REVERT_THRESHOLD) {
directionRef.current = velocity > 0 ? -1 : 1
}
const parallax = Math.abs(velocity) * parallaxAmount * PARALLAX_SCALE
const step = delta * (autoScrollSpeed + parallax) * directionRef.current
let next = offsetRef.current + step
if (next <= -height) next = 0
else if (next >= 0) next = -height + 1
offsetRef.current = next
y.set(reversed ? next : -next - height)
})
return (
<div
ref={containerRef}
style={{ position: "relative", overflow: "hidden" }}
className={`h-full w-full ${className ?? ""}`}
>
<motion.div style={{ y, position: "absolute", top: 0, left: 0, right: 0 }}>
<div ref={measureRef}>{children}</div>
{Array.from({ length: clones }, (_, i) => (
<div key={i} aria-hidden>
{children}
</div>
))}
</motion.div>
</div>
)
}
"use client"
import type { LenisOptions } from "lenis"
import { type LenisRef, ReactLenis } from "lenis/react"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ReactNode, useEffect, useRef } from "react"
type SmoothScrollProps = {
children: ReactNode
options?: LenisOptions
}
/**
* Smooth scroll for the whole page (for now).
*
* Motion is the clock: scroll, animations, and WebGL usually each run on
* their own loop, and can fall out of sync. This provider runs them on a
* single loop, in a fixed order, so they always move together.
*/
export function SmoothScroll({ children, options }: SmoothScrollProps) {
const lenisRef = useRef<LenisRef>(null)
useEffect(() => {
function update(data: FrameData) {
lenisRef.current?.lenis?.raf(data.timestamp)
}
frame.update(update, true)
return () => cancelFrame(update)
}, [])
return (
<ReactLenis root ref={lenisRef} options={{ syncTouch: true, ...options, autoRaf: false }}>
{children}
</ReactLenis>
)
}
Stack as many columns as you want. Alternate their direction with reversed.
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",
]
<div className="flex h-200 gap-2 overflow-hidden">
<InfiniteParallax>
{IMAGES.map((src) => (
<img key={src} src={src} className="mb-2 w-full" alt="" />
))}
</InfiniteParallax>
<InfiniteParallax reversed>
{IMAGES.map((src) => (
<img key={src} src={src} className="mb-2 w-full" alt="" />
))}
</InfiniteParallax>
<InfiniteParallax>
{IMAGES.map((src) => (
<img key={src} src={src} className="mb-2 w-full" alt="" />
))}
</InfiniteParallax>
</div>Works best with smooth scrolling. You can add Smooth Scroll at the root for that:
import { SmoothScroll } from "@/components/smooth-scroll/smooth-scroll";
export default function RootLayout({ children }) {
return <SmoothScroll>{children}</SmoothScroll>;
}| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Items to render in the column. |
reversed | boolean | false | Reverse the column's scroll direction. |
autoScrollSpeed | number | 0.02 | Auto-scroll speed. 0 to disable. |
parallaxAmount | number | 2 | Parallax intensity applied while scrolling. |
className | string | — | Extra classes applied to the column container. |
Motion
React animation library.
Smooth Scroll (Atelier)
Smooth scrolling for the whole page.