Pixel Background
An interactive background featuring tiny pixel particles that ripple outward from the center on hover, then fade away.
Installation
File Structure
Usage
import { PixelBackground } from "@/components/backgrounds/pixel";
export default function MyPage() {
return (
<PixelBackground>
<div className="flex items-center justify-center h-screen">
<h1>Your content here</h1>
</div>
</PixelBackground>
);
}API Reference
PixelBackground
gap?number5Gap in pixels between each particle on the canvas grid.
speed?number35Animation speed from 0 (stopped) to 100 (maximum).
pattern?"center" | "top" | "bottom" | "left" | "right" | "diagonal" | "ascend" | "edges" | "spiral" | "cursor" | "random""center"Controls the reveal animation direction and style.
darkColors?string"#2a2a2a,#3b3b3b,#525252"Comma-separated hex colors used in dark mode.
lightColors?string"#d4d4d4,#bdbdbd,#a3a3a3"Comma-separated hex colors used in light mode.
children?React.ReactNode—Content rendered on top of the pixel canvas.
className?string—Additional CSS classes for the container.
Animation Patterns
The component supports multiple animation patterns that control how pixels appear on hover:
- center — Ripples outward from the center of the container
- top — Reveals from top to bottom
- bottom — Reveals from bottom to top
- left — Reveals from left to right
- right — Reveals from right to left
- diagonal — Reveals along a diagonal from top-left to bottom-right
- ascend — Reveals from bottom-right to top-left
- edges — Reveals from the edges inward
- spiral — Reveals in a spiral pattern from center
- cursor — Ripples outward from the current mouse position
- random — Reveals in random order
Notes
- Canvas-based rendering: Uses HTML5 Canvas for efficient pixel animation
- Responsive: Automatically resizes and re-renders when the container size changes
- Accessibility: Respects the
prefers-reduced-motionmedia query - Color customization: Supports theme-aware colors with separate dark and light mode palettes
- Performance: Optimized animation loop with requestAnimationFrame
- Interactive: Supports hover-based animation triggers, with cursor-relative animations available
Examples
With Custom Colors
<PixelBackground
darkColors="#ff6b6b,#ee5a6f,#c44569"
lightColors="#ffe66d,#ffd93d,#ffc93c"
pattern="spiral"
speed={50}
>
<YourContent />
</PixelBackground>With Cursor Pattern
<PixelBackground pattern="cursor" gap={8} speed={45}>
<YourContent />
</PixelBackground>Credits
Built by Léo. Inspired by React Bits and their Pixel Card component.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
class Pixel {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
x: number;
y: number;
color: string;
speed: number;
size: number;
sizeStep: number;
minSize: number;
maxSizeInteger: number;
maxSize: number;
delay: number;
counter: number;
counterStep: number;
isIdle: boolean;
isReverse: boolean;
isShimmer: boolean;
constructor(
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
speed: number,
delay: number,
) {
this.width = canvas.width;
this.height = canvas.height;
this.ctx = context;
this.x = x;
this.y = y;
this.color = color;
this.speed = this.getRandomValue(0.1, 0.9) * speed;
this.size = 0;
this.sizeStep = Math.random() * 0.4;
this.minSize = 0.5;
this.maxSizeInteger = 2;
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger);
this.delay = delay;
this.counter = 0;
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
this.isIdle = false;
this.isReverse = false;
this.isShimmer = false;
}
getRandomValue(min: number, max: number) {
return Math.random() * (max - min) + min;
}
draw() {
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5;
this.ctx.fillStyle = this.color;
this.ctx.fillRect(
this.x + centerOffset,
this.y + centerOffset,
this.size,
this.size,
);
}
appear() {
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.draw();
}
disappear() {
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true;
return;
} else {
this.size -= 0.1;
}
this.draw();
}
shimmer() {
if (this.size >= this.maxSize) {
this.isReverse = true;
} else if (this.size <= this.minSize) {
this.isReverse = false;
}
if (this.isReverse) {
this.size -= this.speed;
} else {
this.size += this.speed;
}
}
}
function getEffectiveSpeed(value: number, reducedMotion: boolean) {
const min = 0;
const max = 100;
const throttle = 0.001;
if (value <= min || reducedMotion) {
return min;
} else if (value >= max) {
return max * throttle;
} else {
return value * throttle;
}
}
type AnimationPattern =
| "center"
| "top"
| "bottom"
| "left"
| "right"
| "diagonal"
| "ascend"
| "edges"
| "spiral"
| "cursor"
| "random";
function calculateDelay(
pattern: AnimationPattern,
x: number,
y: number,
width: number,
height: number,
mouseX?: number,
mouseY?: number,
): number {
switch (pattern) {
case "top":
return y;
case "bottom":
return height - y;
case "left":
return x;
case "right":
return width - x;
case "diagonal":
return x + y;
case "ascend":
return x + (height - y);
case "edges":
return Math.min(x, width - x, y, height - y);
case "spiral": {
const cx = width / 2;
const cy = height / 2;
const dx = x - cx;
const dy = y - cy;
const angle = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI);
const dist = Math.sqrt(dx * dx + dy * dy);
const maxDist = Math.sqrt(cx * cx + cy * cy);
return ((dist / maxDist) * 3 + angle) * maxDist * 0.5;
}
case "cursor": {
const mx = mouseX ?? width / 2;
const my = mouseY ?? height / 2;
const dx = x - mx;
const dy = y - my;
return Math.sqrt(dx * dx + dy * dy);
}
case "random":
return Math.random() * Math.sqrt(width * width + height * height) * 0.5;
case "center":
default: {
const dx = x - width / 2;
const dy = y - height / 2;
return Math.sqrt(dx * dx + dy * dy);
}
}
}
type PixelBackgroundProps = {
gap?: number;
speed?: number;
pattern?: AnimationPattern;
darkColors?: string;
lightColors?: string;
className?: string;
children?: React.ReactNode;
};
const DEFAULT_DARK_COLORS = "#2a2a2a,#3b3b3b,#525252";
const DEFAULT_LIGHT_COLORS = "#d4d4d4,#bdbdbd,#a3a3a3";
function PixelBackground({
gap = 5,
speed = 35,
pattern = "center",
darkColors = DEFAULT_DARK_COLORS,
lightColors = DEFAULT_LIGHT_COLORS,
className,
children,
}: PixelBackgroundProps) {
const { resolvedTheme } = useTheme();
const colors =
(resolvedTheme === "dark" ? darkColors : lightColors) ?? lightColors;
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const pixelsRef = React.useRef<Pixel[]>([]);
const animationRef = React.useRef<ReturnType<
typeof requestAnimationFrame
> | null>(null);
const timePreviousRef = React.useRef(0);
const reducedMotionRef = React.useRef(false);
React.useEffect(() => {
reducedMotionRef.current = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
timePreviousRef.current = performance.now();
}, []);
const initPixels = React.useCallback(() => {
if (!containerRef.current || !canvasRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
const ctx = canvasRef.current.getContext("2d");
if (!ctx) return;
canvasRef.current.width = width;
canvasRef.current.height = height;
canvasRef.current.style.width = `${width}px`;
canvasRef.current.style.height = `${height}px`;
const colorsArray = colors.split(",");
const pxs: Pixel[] = [];
const gapInt = Math.max(1, Math.floor(gap));
for (let x = 0; x < width; x += gapInt) {
for (let y = 0; y < height; y += gapInt) {
const color =
colorsArray[Math.floor(Math.random() * colorsArray.length)];
const delay = reducedMotionRef.current
? 0
: calculateDelay(pattern, x, y, width, height);
pxs.push(
new Pixel(
canvasRef.current!,
ctx,
x,
y,
color,
getEffectiveSpeed(speed, reducedMotionRef.current),
delay,
),
);
}
}
pixelsRef.current = pxs;
}, [gap, speed, colors, pattern]);
const doAnimate = React.useCallback(function animate(
fnName: "appear" | "disappear",
) {
animationRef.current = requestAnimationFrame(() => animate(fnName));
const timeNow = performance.now();
const timePassed = timeNow - timePreviousRef.current;
const timeInterval = 1000 / 60;
if (timePassed < timeInterval) return;
timePreviousRef.current = timeNow - (timePassed % timeInterval);
const ctx = canvasRef.current?.getContext("2d");
if (!ctx || !canvasRef.current) return;
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
let allIdle = true;
for (let i = 0; i < pixelsRef.current.length; i++) {
const pixel = pixelsRef.current[i];
pixel[fnName]();
if (!pixel.isIdle) {
allIdle = false;
}
}
if (allIdle && animationRef.current !== null) {
cancelAnimationFrame(animationRef.current);
}
}, []);
const handleAnimation = React.useCallback(
(name: "appear" | "disappear") => {
if (animationRef.current !== null) {
cancelAnimationFrame(animationRef.current);
}
animationRef.current = requestAnimationFrame(() => doAnimate(name));
},
[doAnimate],
);
const onMouseEnter = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (pattern === "cursor" && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
for (const pixel of pixelsRef.current) {
pixel.delay = calculateDelay(
"cursor",
pixel.x,
pixel.y,
rect.width,
rect.height,
mx,
my,
);
pixel.counter = 0;
}
}
handleAnimation("appear");
},
[handleAnimation, pattern],
);
const onMouseLeave = React.useCallback(
() => handleAnimation("disappear"),
[handleAnimation],
);
React.useEffect(() => {
initPixels();
const observer = new ResizeObserver(() => {
initPixels();
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => {
observer.disconnect();
if (animationRef.current !== null) {
cancelAnimationFrame(animationRef.current);
}
};
}, [initPixels]);
return (
<div
ref={containerRef}
data-slot="pixel-background"
className={cn("relative overflow-hidden isolate", className)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<canvas
className="absolute inset-0 w-full h-full pointer-events-none"
ref={canvasRef}
/>
<div className="relative z-[1]">{children}</div>
</div>
);
}
export { PixelBackground, type PixelBackgroundProps, type AnimationPattern };