An interactive background featuring tiny pixel particles that ripple outward from the center on hover, then fade away.
Made by LéoHover me
import type { AnimationPattern } from "@/components/unlumen-ui/pixel";
import { PixelBackground } from "@/components/unlumen-ui/pixel";
type PixelBackgroundDemoProps = {
gap: number;
speed: number;
pattern: AnimationPattern;
darkColors: string;
lightColors: string;
};
export const PixelBackgroundDemo = ({
gap,
speed,
pattern,
darkColors,
lightColors,
}: PixelBackgroundDemoProps) => {
return (
<PixelBackground
gap={gap}
speed={speed}
pattern={pattern}
darkColors={darkColors}
lightColors={lightColors}
className="absolute inset-0 flex items-center justify-center"
>
Hover me
</PixelBackground>
);
};Installation
Install the following dependencies:
Copy and paste the following code into your project:
"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 };Update the import paths to match your project setup.
Examples
Grid
Pixel
Cursor
Animation starts from where the mouse enters.
Pixel
Edges
Pixels appear from all edges inward.
Pixel
Random
A Random pattern that combines distance and angle
import { PixelBackground } from "@/components/unlumen-ui/pixel";
export const PixelBackgroundGridDemo = () => {
return (
<div className="absolute m-12 border border-border/80 inset-0 grid grid-cols-3 overflow-hidden divide-x divide-border/80">
<PixelBackground
pattern="cursor"
darkColors="#0d1b4b,#1a3a8f,#2563eb"
lightColors="#bfdbfe,#93c5fd,#3b82f6"
className="size-full flex flex-col"
>
<h1 className="text-4xl p-6 pb-1">
Pixel <br /> Cursor
</h1>
<p className="text-sm text-muted-foreground p-6 pt-3">
Animation starts from where the mouse enters.
</p>
</PixelBackground>
<PixelBackground
pattern="edges"
darkColors="#2a2a2a,#3b3b3b,#525252"
lightColors="#d4d4d4,#bdbdbd,#a3a3a3"
className="size-full flex flex-col"
>
<h1 className="text-4xl p-6 pb-1">
Pixel <br /> Edges
</h1>
<p className="text-sm text-muted-foreground p-6 pt-3">
Pixels appear from all edges inward.
</p>
</PixelBackground>
<PixelBackground
pattern="random"
darkColors="#606c38,#fefae0,#bc6c25"
lightColors="#264653,#2a9d8f,#e9c46a,#f4a261"
className="size-full flex flex-col"
>
<h1 className="text-4xl p-6 pb-1">
Pixel <br /> Random
</h1>
<p className="text-sm text-muted-foreground p-6 pt-3">
A Random pattern that combines distance and angle
</p>
</PixelBackground>
</div>
);
};Usage
<PixelBackground>
<YourContent />
</PixelBackground>API Reference
PixelBackground
| Prop | Type | Default |
|---|---|---|
gap? | number | 5 |
speed? | number | 35 |
pattern? | "center" | "top" | "bottom" | "left" | "right" | "diagonal" | "ascend" | "edges" | "spiral" | "cursor" | "random" | "center" |
darkColors? | string | "#2a2a2a,#3b3b3b,#525252" |
lightColors? | string | "#d4d4d4,#bdbdbd,#a3a3a3" |
children? | React.ReactNode | - |
...props? | React.ComponentProps<"div"> | - |
Credits
- Special thanks to React Bits for the inspiration, particularly the original Pixel Card component.