Skip to content
← components

Dynamic Pixel Grid

new

A canvas grid of pixels that flicker random opacities inside a sine-pulsing circle — a breathing field of static. Themeable, DPR-crisp, reduced-motion aware. Based on Alex Krasikau.

color
shape

Usage

example.tsx
import { PixelGrid } from "~/components/effects/pixel-grid";
<PixelGrid size={360} color="hsl(0 0% 90%)" />
// other mask shapes: "square" | "diamond" | "ring" | "full"
<PixelGrid shape="diamond" density={0.15} />

How it works

It's a single <canvas> and a loop. The grid is ~100×100 little squares, and each one just stores an opacity. Every few frames, a slice of random cells (about 20%) gets bumped to a random opacity, and the rest hold their value. That alone reads as soft TV static.

The circle is what makes it feel alive. A radius breathes in and out on a Math.sin, and when a cell gets picked, it only lights up if it's inside that radius, otherwise it's snapped to zero. So the field of static swells and contracts. I made it themeable (the original drew black on white; this defaults to light pixels for dark backgrounds), scaled it for device pixel ratio so the squares stay crisp, and had it draw one still frame under prefers-reduced-motion.

pixel-grid.ts
// 100x100 cells; each tick, refresh ~20% of them to a random opacity.
// Inside the pulsing circle they flicker; outside they go dark.
const radius = baseR + amp * Math.sin(frame * 0.01);
if (frame % 5 === 0) {
const updates = Math.floor(count * density);
for (let i = 0; i < updates; i++) {
const x = (Math.random() * grid) | 0;
const y = (Math.random() * grid) | 0;
const dx = x * step + pixelSize / 2 - center;
const dy = y * step + pixelSize / 2 - center;
op[y * grid + x] =
dx * dx + dy * dy <= radius * radius
? Math.random() * 0.6 + 0.2
: 0;
}
}

Props

proptypedefaultdescription
sizenumber360Canvas size in px (square).
pixelSizenumber4Pixel side length.
gapnumber4Gap between pixels.
colorstring"hsl(0 0% 90%)"Any CSS color.
densitynumber0.2Fraction of pixels refreshed each tick.
shape"circle" | "square" | "diamond" | "ring" | "full""circle"Mask the flicker to a sine-pulsing shape.

Original by Alex Krasikau.

The full implementation. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).

components/effects/pixel-grid.tsx
import { useEffect, useRef } from "react";
import { cn } from "~/lib/utils";
export interface PixelGridProps {
/** Canvas size in px (square). Default 360. */
size?: number;
/** Pixel side length in px. Default 4. */
pixelSize?: number;
/** Gap between pixels in px. Default 4. */
gap?: number;
/** Pixel color (any CSS color). Default a light grey for dark backgrounds. */
color?: string;
/** Fraction of pixels refreshed each tick (0–1). Default 0.2. */
density?: number;
/** Mask the flicker to a sine-pulsing shape. Default "circle". */
shape?: "circle" | "square" | "diamond" | "ring" | "full";
/** Deprecated alias: `circle={false}` is the same as `shape="full"`. */
circle?: boolean;
className?: string;
}
/**
* A canvas grid of pixels that flicker random opacities, optionally confined to
* a circle whose radius breathes with a sine wave. DPR-aware and crisp; honors
* prefers-reduced-motion (draws one static frame). Based on Alex Krasikau's
* dynamic pixel grid.
*/
export function PixelGrid({
size = 360,
pixelSize = 4,
gap = 4,
color = "hsl(0 0% 90%)",
density = 0.2,
shape: shapeProp,
circle,
className,
}: PixelGridProps) {
const shape = shapeProp ?? (circle === false ? "full" : "circle");
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = size * dpr;
canvas.height = size * dpr;
ctx.scale(dpr, dpr);
const step = pixelSize + gap;
const grid = Math.floor(size / step);
const count = grid * grid;
const op = new Float32Array(count);
const center = size / 2;
const minR = size * 0.2;
const maxR = size * 0.5;
const amp = (maxR - minR) / 2;
const baseR = minR + amp;
const draw = () => {
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = color;
for (let y = 0; y < grid; y++) {
for (let x = 0; x < grid; x++) {
const a = op[y * grid + x];
if (a <= 0) continue;
ctx.globalAlpha = a;
ctx.fillRect(x * step, y * step, pixelSize, pixelSize);
}
}
ctx.globalAlpha = 1;
};
const band = size * 0.06;
const inside = (dx: number, dy: number, r: number) => {
switch (shape) {
case "full":
return true;
case "square":
return Math.max(Math.abs(dx), Math.abs(dy)) <= r;
case "diamond":
return Math.abs(dx) + Math.abs(dy) <= r;
case "ring":
return Math.abs(Math.hypot(dx, dy) - r) <= band;
default:
return dx * dx + dy * dy <= r * r;
}
};
const refresh = (radius: number) => {
const updates = Math.floor(count * density);
for (let i = 0; i < updates; i++) {
const x = Math.floor(Math.random() * grid);
const y = Math.floor(Math.random() * grid);
const idx = y * grid + x;
const dx = x * step + pixelSize / 2 - center;
const dy = y * step + pixelSize / 2 - center;
op[idx] = inside(dx, dy, radius)
? Math.random() * 0.6 + 0.2
: 0;
}
};
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
refresh(baseR);
draw();
return;
}
let raf = 0;
let frame = 0;
const animate = () => {
frame++;
const radius = baseR + amp * Math.sin(frame * 0.01);
if (frame % 5 === 0) refresh(radius);
draw();
raf = requestAnimationFrame(animate);
};
animate();
return () => cancelAnimationFrame(raf);
}, [size, pixelSize, gap, color, density, shape]);
return (
<canvas
ref={ref}
role="img"
aria-label="Animated pixel grid"
className={cn(className)}
style={{ width: size, height: size, maxWidth: "100%" }}
/>
);
}