Atelier UI®

DocsCatalogShader StudioPricingGithub
Docs 1.0.0

tools

  • Browse Catalog
  • Shader Studio
    pro
  • Collage
    new

Documentation

  • Installation
  • How to contribute

Components (45)

  • Orbit Gallery
  • Sphere 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
  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • 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. Pixel Scroll

Pixel Scroll

A grid of pixels that fill in or clear out as you scroll, with optional color hints.

Motion
Canvas
Lenis
Tailwind CSS
https://atelier-ui.com/pixel-scroll

Settings

density
20
color-ratio
0.25
randomness
0.40
See the documentation below for more options.

Install

npx atelier-ui add pixel-scroll
npm install motion
pixel-scroll.tsx
import { useScroll } from "motion/react"
import { type ComponentRef, useEffect, useMemo, useRef, useState } from "react"

const MAX_DENSITY = 200

type Cell = {
    color: string | null
    fillAt: number
    flashAt: number
}

export type PixelScrollProps = {
    density?: number
    colors?: string[]
    colorRatio?: number
    randomness?: number
    direction?: "cover" | "clear" | "sweep"
    scrollDistance?: number
    overlap?: number
    className?: string
}

function cover(cell: Cell, progress: number, settled: string) {
    if (progress > cell.fillAt) return settled
    if (cell.color && progress > cell.flashAt) return cell.color
    return null
}

function clear(cell: Cell, progress: number, settled: string) {
    if (progress < cell.flashAt) return settled
    if (cell.color && progress < cell.fillAt) return cell.color
    return null
}

function sweep(cell: Cell, progress: number, settled: string) {
    if (progress <= 0.5) return cover(cell, progress * 2, settled)
    return clear(cell, (progress - 0.5) * 2, settled)
}

export default function PixelScroll({
    density = 20,
    colors = [],
    colorRatio = 0.25,
    randomness = 0.4,
    direction = "cover",
    scrollDistance = 200,
    overlap = 0,
    className,
}: PixelScrollProps) {
    const [size, setSize] = useState({ width: 0, height: 0, rows: 0, cols: 0 })
    const canvasRef = useRef<ComponentRef<"canvas">>(null)
    const ownTargetRef = useRef<ComponentRef<"section">>(null)
    const cellSize = 1000 / Math.min(density, MAX_DENSITY)

    const { scrollYProgress } = useScroll({
        target: ownTargetRef,
        offset: ["start start", "end end"],
    })

    useEffect(() => {
        const element = canvasRef.current
        if (!element) return

        const observer = new ResizeObserver(([{ contentRect }]) => {
            const { width, height } = contentRect
            if (width && height) {
                const cols = Math.min(MAX_DENSITY, Math.max(1, Math.round(width / cellSize)))
                const rows = Math.max(1, Math.round(height / (width / cols)))
                setSize({ width, height, rows, cols })
            }
        })

        observer.observe(element)
        return () => observer.disconnect()
    }, [cellSize])

    const cells = useMemo(
        () =>
            Array.from({ length: size.cols * size.rows }, (_, index) => {
                const row = Math.floor(index / size.cols)
                const height = (size.rows - 1 - row) / Math.max(1, size.rows - 1)
                const fillAt = Math.min(1, height * (1 - randomness) + Math.random() * randomness)
                const flashes = colors.length > 0 && Math.random() < colorRatio
                const color = flashes ? colors[Math.floor(Math.random() * colors.length)] : null

                return {
                    color,
                    fillAt,
                    flashAt: color ? Math.max(0, fillAt - 0.08) : fillAt,
                }
            }),
        [size.cols, size.rows, colors, colorRatio, randomness],
    )

    useEffect(() => {
        function paintOnScroll() {
            const canvas = canvasRef.current
            if (!canvas || !size.width || !size.height) return
            const context = canvas.getContext("2d")
            if (!context) return

            const { width, height, rows, cols } = size
            const pixelRatio = window.devicePixelRatio || 1
            canvas.width = Math.round(width * pixelRatio)
            canvas.height = Math.round(height * pixelRatio)
            context.scale(pixelRatio, pixelRatio)

            const cellWidth = width / cols
            const cellHeight = height / rows

            let fillOf = cover
            if (direction === "clear") fillOf = clear
            if (direction === "sweep") fillOf = sweep

            let settledColor = getComputedStyle(canvas).color

            const paint = (progress: number) => {
                context.clearRect(0, 0, width, height)
                for (let index = 0; index < cells.length; index++) {
                    const fillColor = fillOf(cells[index], progress, settledColor)

                    if (fillColor) {
                        const cellX = (index % cols) * cellWidth
                        const cellY = Math.floor(index / cols) * cellHeight
                        context.fillStyle = fillColor
                        context.fillRect(
                            Math.floor(cellX),
                            Math.floor(cellY),
                            Math.ceil(cellWidth),
                            Math.ceil(cellHeight),
                        )
                    }
                }
            }

            paint(scrollYProgress.get())
            const unsubscribe = scrollYProgress.on("change", paint)

            // Canvas pixels are painted by hand, so unlike CSS they don't restyle when the root's class or data-theme changes. Repaint when they do.
            const observer = new MutationObserver(() => {
                settledColor = getComputedStyle(canvas).color
                paint(scrollYProgress.get())
            })

            observer.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ["class", "data-theme"],
            })

            return () => {
                unsubscribe()
                observer.disconnect()
            }
        }

        return paintOnScroll()
    }, [size, cells, direction, scrollYProgress])

    // Canvas redraws the whole grid each scroll frame in a single loop, instead of using a div per pixel (for performance reasons)
    const canvas = <canvas ref={canvasRef} className={`block size-full ${className ?? ""}`} />

    return (
        <section
            ref={ownTargetRef}
            className="relative"
            style={{ height: `${scrollDistance + 100}vh`, margin: `${-overlap / 2}vh 0` }}
        >
            <div className="sticky top-0 h-screen">{canvas}</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>
    )
}
prompt.md
## Integrate the <PixelScroll /> component from Atelier UI

You are helping integrate an open-source React component into an existing application.

### Component: PixelScroll
### Description: A grid of pixels that fill in or clear out as you scroll, with optional color hints.
### Dependencies: motion, lenis

---

### Usage Example

By default the component creates its own scroll section and pins the canvas on screen while the grid fills in. This is what the demo above uses.

```tsx
<PixelScroll colors={["#FBBE3C", "#D3D3D3"]} className="text-[#191d24] dark:text-[#191b21]" />
```

Works best with smooth scrolling. You can add [Smooth Scroll](https://atelier-ui.com/docs/components/primitive/smooth-scroll) at the root for that:

```tsx title="Root layout"
import { SmoothScroll } from "@/components/smooth-scroll";

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

### Color

- `text-white` sets the pixel color.
- `bg-black` sets the background.

```tsx
<PixelScroll className="bg-white text-black" />
```

### Direction

- `"cover"` fills the grid in, bottom-to-top, as you scroll.
- `"clear"` starts filled and clears out, bottom-to-top, as you scroll.
- `"sweep"` fills in, then clears out, within a single scroll.

```tsx title="direction=cover"
<PixelScroll direction="cover" className="bg-black text-white" />
```

```tsx title="direction=clear"
<PixelScroll direction="clear" className="bg-black text-white" />
```

```tsx title="direction=sweep"
<PixelScroll direction="sweep" className="bg-black text-white" />
```

---

### Props

| Name | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `density` | `number` | `20` | Pixel columns per 1000px of width, capped at 200, so squares keep the same size on every screen. The row count is derived from the canvas height so pixels stay square. |
| `colors` | `string[]` | `[]` | CSS colors a pixel can briefly show before it turns the text color. When empty, pixels turn the text color with no color hint. |
| `colorRatio` | `number` | `0.25` | Fraction of pixels that show a color hint, from 0 to 1. Has no effect when `colors` is empty. |
| `randomness` | `number` | `0.4` | How far each pixel's fill point deviates from the bottom-to-top gradient, from 0 to 1. At `0` the grid fills as an ordered gradient; at `1` the fill points are fully scattered. |
| `direction` | `"cover" \| "clear" \| "sweep"` | `"cover"` | How the grid responds to scroll. `"cover"` fills it in, `"clear"` empties it, and `"sweep"` does both in one scroll. |
| `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. |
| `className` | `string` | — | Classes forwarded to the canvas element. |

---

### Full Component Source

#### src/components/pixel-scroll/pixel-scroll.tsx

```tsx
import { useScroll } from "motion/react"
import { type ComponentRef, useEffect, useMemo, useRef, useState } from "react"

const MAX_DENSITY = 200

type Cell = {
    color: string | null
    fillAt: number
    flashAt: number
}

export type PixelScrollProps = {
    density?: number
    colors?: string[]
    colorRatio?: number
    randomness?: number
    direction?: "cover" | "clear" | "sweep"
    scrollDistance?: number
    overlap?: number
    className?: string
}

function cover(cell: Cell, progress: number, settled: string) {
    if (progress > cell.fillAt) return settled
    if (cell.color && progress > cell.flashAt) return cell.color
    return null
}

function clear(cell: Cell, progress: number, settled: string) {
    if (progress < cell.flashAt) return settled
    if (cell.color && progress < cell.fillAt) return cell.color
    return null
}

function sweep(cell: Cell, progress: number, settled: string) {
    if (progress <= 0.5) return cover(cell, progress * 2, settled)
    return clear(cell, (progress - 0.5) * 2, settled)
}

export default function PixelScroll({
    density = 20,
    colors = [],
    colorRatio = 0.25,
    randomness = 0.4,
    direction = "cover",
    scrollDistance = 200,
    overlap = 0,
    className,
}: PixelScrollProps) {
    const [size, setSize] = useState({ width: 0, height: 0, rows: 0, cols: 0 })
    const canvasRef = useRef<ComponentRef<"canvas">>(null)
    const ownTargetRef = useRef<ComponentRef<"section">>(null)
    const cellSize = 1000 / Math.min(density, MAX_DENSITY)

    const { scrollYProgress } = useScroll({
        target: ownTargetRef,
        offset: ["start start", "end end"],
    })

    useEffect(() => {
        const element = canvasRef.current
        if (!element) return

        const observer = new ResizeObserver(([{ contentRect }]) => {
            const { width, height } = contentRect
            if (width && height) {
                const cols = Math.min(MAX_DENSITY, Math.max(1, Math.round(width / cellSize)))
                const rows = Math.max(1, Math.round(height / (width / cols)))
                setSize({ width, height, rows, cols })
            }
        })

        observer.observe(element)
        return () => observer.disconnect()
    }, [cellSize])

    const cells = useMemo(
        () =>
            Array.from({ length: size.cols * size.rows }, (_, index) => {
                const row = Math.floor(index / size.cols)
                const height = (size.rows - 1 - row) / Math.max(1, size.rows - 1)
                const fillAt = Math.min(1, height * (1 - randomness) + Math.random() * randomness)
                const flashes = colors.length > 0 && Math.random() < colorRatio
                const color = flashes ? colors[Math.floor(Math.random() * colors.length)] : null

                return {
                    color,
                    fillAt,
                    flashAt: color ? Math.max(0, fillAt - 0.08) : fillAt,
                }
            }),
        [size.cols, size.rows, colors, colorRatio, randomness],
    )

    useEffect(() => {
        function paintOnScroll() {
            const canvas = canvasRef.current
            if (!canvas || !size.width || !size.height) return
            const context = canvas.getContext("2d")
            if (!context) return

            const { width, height, rows, cols } = size
            const pixelRatio = window.devicePixelRatio || 1
            canvas.width = Math.round(width * pixelRatio)
            canvas.height = Math.round(height * pixelRatio)
            context.scale(pixelRatio, pixelRatio)

            const cellWidth = width / cols
            const cellHeight = height / rows

            let fillOf = cover
            if (direction === "clear") fillOf = clear
            if (direction === "sweep") fillOf = sweep

            let settledColor = getComputedStyle(canvas).color

            const paint = (progress: number) => {
                context.clearRect(0, 0, width, height)
                for (let index = 0; index < cells.length; index++) {
                    const fillColor = fillOf(cells[index], progress, settledColor)

                    if (fillColor) {
                        const cellX = (index % cols) * cellWidth
                        const cellY = Math.floor(index / cols) * cellHeight
                        context.fillStyle = fillColor
                        context.fillRect(
                            Math.floor(cellX),
                            Math.floor(cellY),
                            Math.ceil(cellWidth),
                            Math.ceil(cellHeight),
                        )
                    }
                }
            }

            paint(scrollYProgress.get())
            const unsubscribe = scrollYProgress.on("change", paint)

            // Canvas pixels are painted by hand, so unlike CSS they don't restyle when the root's class or data-theme changes. Repaint when they do.
            const observer = new MutationObserver(() => {
                settledColor = getComputedStyle(canvas).color
                paint(scrollYProgress.get())
            })

            observer.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ["class", "data-theme"],
            })

            return () => {
                unsubscribe()
                observer.disconnect()
            }
        }

        return paintOnScroll()
    }, [size, cells, direction, scrollYProgress])

    // Canvas redraws the whole grid each scroll frame in a single loop, instead of using a div per pixel (for performance reasons)
    const canvas = <canvas ref={canvasRef} className={`block size-full ${className ?? ""}`} />

    return (
        <section
            ref={ownTargetRef}
            className="relative"
            style={{ height: `${scrollDistance + 100}vh`, margin: `${-overlap / 2}vh 0` }}
        >
            <div className="sticky top-0 h-screen">{canvas}</div>
        </section>
    )
}
```

#### src/components/smooth-scroll/smooth-scroll.tsx

```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>
    )
}
```

---

### Integration Instructions

1. If you can execute shell commands, run `npx atelier-ui add pixel-scroll` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: motion, lenis.
3. Copy each file from the component source above to the exact path shown.
4. Render `<PixelScroll />` where it belongs in the app, using the usage example as a starting point and the props table to adjust it.

Full documentation: https://atelier-ui.com/en/docs/components/scroll/pixel-scroll

Usage

By default the component creates its own scroll section and pins the canvas on screen while the grid fills in. This is what the demo above uses.

<PixelScroll colors={["#FBBE3C", "#D3D3D3"]} className="text-[#191d24] dark:text-[#191b21]" />

Works best with smooth scrolling. You can add Smooth Scroll at the root for that:

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

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

Color

  • text-white sets the pixel color.
  • bg-black sets the background.
<PixelScroll className="bg-white text-black" />

Direction

  • "cover" fills the grid in, bottom-to-top, as you scroll.
  • "clear" starts filled and clears out, bottom-to-top, as you scroll.
  • "sweep" fills in, then clears out, within a single scroll.
direction=cover
<PixelScroll direction="cover" className="bg-black text-white" />
direction=clear
<PixelScroll direction="clear" className="bg-black text-white" />
direction=sweep
<PixelScroll direction="sweep" className="bg-black text-white" />

API

NameTypeDefaultDescription
densitynumber20Pixel columns per 1000px of width, capped at 200, so squares keep the same size on every screen. The row count is derived from the canvas height so pixels stay square.
colorsstring[][]CSS colors a pixel can briefly show before it turns the text color. When empty, pixels turn the text color with no color hint.
colorRationumber0.25Fraction of pixels that show a color hint, from 0 to 1. Has no effect when colors is empty.
randomnessnumber0.4How far each pixel's fill point deviates from the bottom-to-top gradient, from 0 to 1. At 0 the grid fills as an ordered gradient; at 1 the fill points are fully scattered.
direction"cover" | "clear" | "sweep""cover"How the grid responds to scroll. "cover" fills it in, "clear" empties it, and "sweep" does both in one scroll.
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.
classNamestring—Classes forwarded to the canvas element.
  • Install
  • Usage
  • Color
  • Direction
  • API
Star on githubBuy me a coffeellms.txt