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. Scattered Scroll

Scattered Scroll

Items that spread apart horizontally while scrolling.

https://atelier-ui.com/scattered-scroll
Prompt
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-scroll

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

npm install motion
scattered-scroll.tsx
"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>
    )
}
smooth-scroll.tsx
"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:

Root layout
import { SmoothScroll } from "@/components/smooth-scroll/smooth-scroll";

export default function RootLayout({ children }) {
  return <SmoothScroll>{children}</SmoothScroll>;
}

NameTypeDefaultDescription
childrenReactNode—The items to animate. Any JSX element allowed. Required.
scrollDistancenumber200How long the animation lasts. 100 equals one viewport of scrolling.
overlapnumber0Shortens 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.

Star on githubBuy me a coffeellms.txt