Pixel Background

An interactive background featuring tiny pixel particles that ripple outward from the center on hover, then fade away.

Made by Léo
Open in
Hover me
demo-pixel.tsx
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:

components/unlumen-ui/pixel.tsx
"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

demo-pixel-grid.tsx
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

PropTypeDefault
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

Built by Léo from Unlumen :3

Last updated: 3/15/2026