Atelier UI®

Read the docsGithub
Docs 1.0.0

Getting started

  • Browse Catalog
  • Installation
  • How to contribute
  • Code of conduct
  • Fluid Scene

Components (32)

  • Clip Reveal
  • Stripe Wipe
  • Sweep Exit
  • Dither Flow
    pro
  • Glowing Fog
    pro
  • Halftone Glow
    pro
  • Sphere Gallery
  • Fluid Distortion
  • Image Trail
  • Lens Media
  • Liquid Media
  • Magnetic Dot Grid
  • Pixel Media
  • Pixel Trail
  • Pixelated Text
  • Text Bounce
  • Text Fluid
  • Text Roll
  • Text Scramble
  • Curve Media
  • Elastic Stick
    pro
  • Infinite Gallery
  • Infinite Parallax
  • Infinite Zoom
  • Pixel Scroll
  • Scattered 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, type RefObject, 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"
    scrollTargetRef?: RefObject<HTMLElement | null>
    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",
    scrollTargetRef,
    className,
}: PixelScrollProps) {
    const [size, setSize] = useState({ width: 0, height: 0, rows: 0 })
    const canvasRef = useRef<ComponentRef<"canvas">>(null)
    const cols = Math.min(density, MAX_DENSITY)

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

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

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

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

    const cells = useMemo(
        () =>
            Array.from({ length: cols * size.rows }, (_, index) => {
                const row = Math.floor(index / 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,
                }
            }),
        [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 } = 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, cols, direction, scrollYProgress])

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

Usage

By default the reveal is driven by the component's own scroll position, filling in as it passes through the viewport.

<div className="h-screen" />
<PixelScroll className="h-screen bg-black text-white" />
<div className="h-screen" />

Color

  • text-white sets the pixel color.
  • bg-black sets the background.
<PixelScroll className="size-full 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="size-full bg-black text-white" />
direction=clear
<PixelScroll direction="clear" className="size-full bg-black text-white" />
direction=sweep
<PixelScroll direction="sweep" className="size-full bg-black text-white" />

Pinned to a scroll section

Pass scrollTargetRef with a sticky wrapper to pin the canvas while a taller section scrolls past.

In the demo we pin it over a fixed div so the grid looks like it covers the content.

const scrollTargetRef = useRef<HTMLDivElement>(null)

<div ref={scrollTargetRef} className="relative h-[300vh]">
  <div className="sticky top-0 h-screen">
    <PixelScroll scrollTargetRef={scrollTargetRef} className="size-full bg-black text-white" />
  </div>
</div>

Section height

When using this pinned scroll technique, the scroll section must be taller than 100vh, otherwise the grid snaps straight to filled.


API

NameTypeDefaultDescription
densitynumber20Number of pixel columns across the canvas, capped at 200. 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.
scrollTargetRefRefObject<HTMLElement | null>—Element whose scroll position drives the reveal. Defaults to the component's own canvas.
classNamestring—Classes forwarded to the canvas element.
  • Install
  • Usage
  • Color
  • Direction
  • Pinned to a scroll section
  • API
Star on githubBuy me a coffeellms.txt