Items that spread apart horizontally while scrolling.
Add Atelier's Scattered Scroll to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/scattered-scroll
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.This command will install all the dependencies this component uses.
npx shadcn@latest add @atelier/scattered-scrollInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install motion"use client"
import { type MotionValue, motion, useScroll, useTransform } from "motion/react"
import {
Children,
type ComponentRef,
isValidElement,
type ReactNode,
type RefObject,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
export type ScatteredScrollProps = {
children: ReactNode
scrollDistance?: number
overlap?: number
}
// (used instead of Math.random) Avoid hydration error on next.js
function seededRandom(seed: number): number {
const x = Math.sin(seed + 1) * 10000
return x - Math.floor(x)
}
const Item = ({
children,
progress,
xValue,
index,
itemRef,
}: {
children: ReactNode
progress: MotionValue<number>
xValue: number
index: number
itemRef?: RefObject<HTMLDivElement | null>
}) => {
/**
* Tweak options:
* xPercent: horizontal offset between x and x (30 and 40 default).
* rotation: random rotation between x and x (10 and 20 default).
* yOffset: vertical offset in px (90 default).
*/
const { xPercent, rotation, yOffset } = useMemo(
() => ({
xPercent: (seededRandom(index * 2) * 10 + 30) * (index % 2 === 0 ? 1 : -1),
rotation: (seededRandom(index * 2 + 1) * 10 + 10) * (index % 2 === 0 ? 1 : -1),
yOffset: (index % 2 === 0 ? 1 : -1) * 90,
}),
[index],
)
const yTranslate = useTransform(progress, [0, 0.5, 1], [yOffset, 0, -yOffset])
const xTranslate = useTransform(progress, [0, 1], [xValue, -xValue])
const rotate = useTransform(progress, [0, 1], [rotation, -rotation])
const xPercentValue = useTransform(progress, [0, 1], [xPercent, -xPercent])
const scatteredX = useTransform(
[xTranslate, xPercentValue],
([px, percent]) => `calc(${px}px + ${percent}%)`,
)
return (
<motion.div
className="will-change-transform"
ref={itemRef}
style={{
x: scatteredX,
rotate: rotate,
y: yTranslate,
}}
>
{children}
</motion.div>
)
}
export default function ScatteredScroll({
children,
scrollDistance = 200,
overlap = 0,
}: ScatteredScrollProps) {
const childrenArray = Children.toArray(children).filter(isValidElement)
const firstItemRef = useRef<ComponentRef<"div">>(null)
const ownTargetRef = useRef<ComponentRef<"section">>(null)
const [xValue, setXValue] = useState(0)
useLayoutEffect(() => {
if (typeof window === "undefined") return
const update = () => {
const containerWidth = window.innerWidth * 0.5
const itemWidth = firstItemRef.current?.getBoundingClientRect().width ?? 0
setXValue(containerWidth + itemWidth * 0.5 * childrenArray.length)
}
update()
window.addEventListener("resize", update)
return () => window.removeEventListener("resize", update)
}, [childrenArray.length])
const { scrollYProgress } = useScroll({
offset: ["start start", "end end"],
target: ownTargetRef,
})
const items = childrenArray.map((child, index) => (
<Item
xValue={xValue}
progress={scrollYProgress}
index={index}
key={index}
itemRef={index === 0 ? firstItemRef : undefined}
>
{child}
</Item>
))
return (
<section
ref={ownTargetRef}
className="relative overflow-x-clip"
style={{ height: `${scrollDistance + 100}vh`, margin: `${-overlap / 2}vh 0` }}
>
<div className="sticky top-0 flex h-screen items-center justify-center gap-2">
{items}
</div>
</section>
)
}
"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>
)
}
The component takes the items as children. Any element works.
const IMAGES = [
"https://picsum.photos/seed/atelier-1/800/1000",
"https://picsum.photos/seed/atelier-2/800/1000",
"https://picsum.photos/seed/atelier-3/800/1000",
"https://picsum.photos/seed/atelier-4/800/1000",
"https://picsum.photos/seed/atelier-5/800/1000",
]
<ScatteredScroll overlap={100} scrollDistance={350}>
{IMAGES.map((src) => (
<img
key={src}
src={src}
alt=""
className="w-[30vw] aspect-[5/7] object-cover rounded-xl"
/>
))}
</ScatteredScroll>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 | — | The items to animate. Any JSX element allowed. Required. |
scrollDistance | number | 200 | How long the animation lasts. 100 equals one viewport of scrolling. |
overlap | number | 0 | Shortens the empty space before and after the animation, so the animation starts sooner. 100 equals one viewport of scrolling. |
Motion
React animation library.
Smooth Scroll (Atelier)
Smooth scrolling for the whole page.