A grid of pixels that fill in or clear out as you scroll, with optional color hints.
Add Atelier's Pixel Scroll to my app.
If there is no components.json, run: npx shadcn@latest init -d
Then run: npx shadcn@latest add @atelier/pixel-scroll
That writes the atelier-ui skill under .agents/skills and .claude/skills. Follow it.
Props: density={20} colorRatio={0.25} randomness={0.4}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/pixel-scrollInstall the dependencies first, then feel free to copy the files into your project as you see fit.
npm install motion"use client"
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>
)
}
"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>
)
}
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:
import { SmoothScroll } from "@/components/smooth-scroll/smooth-scroll";
export default function RootLayout({ children }) {
return <SmoothScroll>{children}</SmoothScroll>;
}text-white sets the pixel color.bg-black sets the background.<PixelScroll className="bg-white text-black" />"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.<PixelScroll direction="cover" className="bg-black text-white" /><PixelScroll direction="clear" className="bg-black text-white" /><PixelScroll direction="sweep" className="bg-black text-white" />| 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. |
Motion
React animation library.
Smooth Scroll (Atelier)
Smooth scrolling for the whole page.