Skip to content
jswnthui

Background

Blinking Squares

A high-density 60 FPS canvas grid of fine dark squares with an interactive mouse hover animation that leaves a smooth decaying light trail.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/blinking-squares.tsx
    import React, { useEffect, useRef, useState } from "react";
    import { cn } from "@/lib/utils";
    
    export interface BlinkingSquaresProps {
      /** Optional array of image URLs to render into pixel squares and transition between on click */
      images?: string[];
      /** Square dimensions in pixels (width and height) */
      squareSize?: number;
      /** Gap between adjacent squares in pixels */
      gap?: number;
      /** Primary square stroke / idle outline color */
      borderColor?: string;
      /** Glow / highlight color when a square is hovered / lit */
      glowColor?: string;
      /** Cursor trail brush radius in pixels */
      hoverRadius?: number;
      /** Duration of hover trail decay in seconds */
      decayDuration?: number;
      /** Optional background / ambient idle square opacity */
      baseAlpha?: number;
      /** Deprecated alias for hoverRadius for backward compatibility */
      interactiveRadius?: number;
      /** Deprecated density prop kept for backward compatibility */
      density?: number;
      /** Deprecated minDuration prop kept for backward compatibility */
      minDuration?: number;
      /** Deprecated maxDuration prop kept for backward compatibility */
      maxDuration?: number;
      /** Optional container class names */
      className?: string;
      /** Optional children overlay */
      children?: React.ReactNode;
    }
    
    interface SquareState {
      x: number;
      y: number;
      col: number;
      row: number;
      alpha: number; // 0 (idle) to 1 (full glow trail)
      fromImgIndex: number;
      toImgIndex: number;
      transitionProgress: number;
      isTransitioning: boolean;
    }
    
    interface Ripple {
      x: number;
      y: number;
      radius: number;
      maxRadius: number;
      speed: number;
      waveWidth: number;
      intensity: number;
      age: number;
      lifeTime: number;
      frequency: number;
      targetImgIndex?: number;
    }
    
    export function BlinkingSquares({
      images,
      squareSize = 14,
      gap = 3,
      borderColor,
      glowColor,
      hoverRadius = 45,
      decayDuration = 1.1,
      baseAlpha = 0.04,
      interactiveRadius,
      className,
      children,
    }: BlinkingSquaresProps) {
      const canvasRef = useRef<HTMLCanvasElement | null>(null);
      const containerRef = useRef<HTMLDivElement | null>(null);
      const currentImgIndexRef = useRef(0);
      const loadedImagesRef = useRef<HTMLImageElement[]>([]);
      const [imagesLoaded, setImagesLoaded] = useState(false);
    
      const mouseRef = useRef<{ x: number; y: number; active: boolean }>({
        x: -1000,
        y: -1000,
        active: false,
      });
      const ripplesRef = useRef<Ripple[]>([]);
    
      const effectiveRadius = interactiveRadius !== undefined ? interactiveRadius : hoverRadius;
    
      // Preload images robustly
      useEffect(() => {
        if (!images || images.length === 0) {
          setImagesLoaded(true);
          return;
        }
        let loadedCount = 0;
        const loadedList: HTMLImageElement[] = new Array(images.length);
    
        images.forEach((src, idx) => {
          const img = new Image();
          img.src = src;
          const onDone = () => {
            loadedList[idx] = img;
            loadedCount++;
            if (loadedCount === images.length) {
              loadedImagesRef.current = loadedList;
              setImagesLoaded(true);
            }
          };
    
          if (img.complete && img.naturalWidth > 0) {
            onDone();
          } else {
            img.onload = onDone;
            img.onerror = () => {
              console.warn(`Failed to load image: ${src}`);
              onDone();
            };
          }
        });
      }, [images]);
    
      useEffect(() => {
        const canvas = canvasRef.current;
        const container = containerRef.current;
        if (!canvas || !container) return;
    
        const ctx = canvas.getContext("2d", { alpha: true });
        if (!ctx) return;
    
        let animationFrameId: number;
        let squares: SquareState[] = [];
        let cols = 0;
        let rows = 0;
        let lastTime = performance.now();
    
        const resize = () => {
          const rect = container.getBoundingClientRect();
          if (rect.width === 0 || rect.height === 0) return;
    
          const dpr = Math.min(window.devicePixelRatio || 1, 2);
          canvas.width = rect.width * dpr;
          canvas.height = rect.height * dpr;
          canvas.style.width = `${rect.width}px`;
          canvas.style.height = `${rect.height}px`;
          ctx.setTransform(1, 0, 0, 1, 0, 0);
          ctx.scale(dpr, dpr);
    
          const step = squareSize + gap;
          cols = Math.ceil(rect.width / step) + 1;
          rows = Math.ceil(rect.height / step) + 1;
    
          // Preserve existing alphas if resizing slightly
          const prevMap = new Map<string, number>();
          for (const s of squares) {
            if (s.alpha > 0.01) {
              prevMap.set(`${s.col},${s.row}`, s.alpha);
            }
          }
    
          squares = [];
          for (let r = 0; r < rows; r++) {
            for (let c = 0; c < cols; c++) {
              const x = c * step;
              const y = r * step;
              const existingAlpha = prevMap.get(`${c},${r}`) || 0;
              squares.push({
                x,
                y,
                col: c,
                row: r,
                alpha: existingAlpha,
                fromImgIndex: currentImgIndexRef.current,
                toImgIndex: currentImgIndexRef.current,
                transitionProgress: 1,
                isTransitioning: false,
              });
            }
          }
        };
    
        resize();
        const resizeObserver = new ResizeObserver(() => {
          resize();
        });
        resizeObserver.observe(container);
    
        const isDark =
          document.documentElement.classList.contains("dark") ||
          window.matchMedia("(prefers-color-scheme: dark)").matches;
    
        // Dark clear visible outlines for idle squares
        const computedBorder =
          borderColor ||
          (isDark ? "rgba(255, 255, 255, 0.18)" : "rgba(0, 0, 0, 0.22)");
    
        // Vibrant trail highlight
        const computedGlow =
          glowColor ||
          (isDark ? "rgba(255, 255, 255, 0.96)" : "rgba(20, 20, 24, 0.94)");
    
        const onPointerMove = (e: PointerEvent) => {
          const rect = container.getBoundingClientRect();
          mouseRef.current = {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top,
            active: true,
          };
        };
    
        const onPointerLeave = () => {
          mouseRef.current.active = false;
        };
    
        const onPointerDown = (e: PointerEvent) => {
          const rect = container.getBoundingClientRect();
          const x = e.clientX - rect.left;
          const y = e.clientY - rect.top;
    
          let nextIndex = 0;
          if (images && images.length > 1) {
            nextIndex = (currentImgIndexRef.current + 1) % images.length;
            currentImgIndexRef.current = nextIndex;
          }
    
          // Distance to farthest corner so ripples reach every corner of the canvas
          const maxCornerDist = Math.max(
            Math.hypot(x, y),
            Math.hypot(rect.width - x, y),
            Math.hypot(x, rect.height - y),
            Math.hypot(rect.width - x, rect.height - y)
          );
          const targetRadius = maxCornerDist + 180;
    
          // Clean, single fluid capillary wavefront (streamlined for instant responsiveness and peak 60 FPS performance)
          const waveConfig = [
            { delay: 0, speed: 420, width: 190, intensity: 0.9, freq: 0.05, life: (targetRadius / 420) * 1.15 },
          ];
    
          waveConfig.forEach(({ delay, speed, width, intensity, freq, life }) => {
            if (delay === 0) {
              ripplesRef.current.push({
                x,
                y,
                radius: 0,
                maxRadius: targetRadius,
                speed,
                waveWidth: width,
                intensity,
                age: 0,
                lifeTime: life,
                frequency: freq,
                targetImgIndex: nextIndex,
              });
            } else {
              setTimeout(() => {
                ripplesRef.current.push({
                  x,
                  y,
                  radius: 0,
                  maxRadius: targetRadius,
                  speed,
                  waveWidth: width,
                  intensity,
                  age: 0,
                  lifeTime: life,
                  frequency: freq,
                  targetImgIndex: nextIndex,
                });
              }, delay);
            }
          });
        };
    
        container.addEventListener("pointermove", onPointerMove);
        container.addEventListener("pointerleave", onPointerLeave);
        container.addEventListener("pointerdown", onPointerDown);
    
        const render = (currentTime: number) => {
          const delta = Math.min((currentTime - lastTime) / 1000, 0.05);
          lastTime = currentTime;
    
          const rect = container.getBoundingClientRect();
          ctx.clearRect(0, 0, rect.width, rect.height);
    
          const { x: mx, y: my, active: mouseActive } = mouseRef.current;
          const decayRate = 1 / Math.max(decayDuration, 0.1);
    
          // Update active ripples with smooth lifecycles
          const activeRipples = ripplesRef.current;
          for (let r = activeRipples.length - 1; r >= 0; r--) {
            const rip = activeRipples[r];
            rip.age += delta;
            rip.radius += rip.speed * delta;
            if (rip.age >= rip.lifeTime || rip.radius >= rip.maxRadius + rip.waveWidth) {
              activeRipples.splice(r, 1);
            }
          }
    
          const hasRipples = activeRipples.length > 0;
          const effRadSq = effectiveRadius * effectiveRadius;
          const hasImages = Boolean(images && images.length > 0 && imagesLoaded);
          const imgs = loadedImagesRef.current;
    
          // Track displaced positions for both base grid and lit overlay
          const displacedPos: { rx: number; ry: number }[] = [];
    
          ctx.beginPath();
          for (let i = 0; i < squares.length; i++) {
            const sq = squares[i];
            const centerX = sq.x + squareSize / 2;
            const centerY = sq.y + squareSize / 2;
    
            // 1. Mouse hover touch trail (gentle intensity)
            if (mouseActive && effectiveRadius > 0) {
              const dx = centerX - mx;
              const dy = centerY - my;
              const distSq = dx * dx + dy * dy;
              if (distSq < effRadSq) {
                const factor = 1 - Math.sqrt(distSq) / effectiveRadius;
                const targetIntensity = factor * factor * (hasImages ? 0.6 : 1.0);
                if (targetIntensity > sq.alpha) {
                  sq.alpha = targetIntensity;
                }
              }
            }
    
            // 2. Silky fluid water turbulence calculation
            let rippleAlpha = 0;
            let dispX = 0;
            let dispY = 0;
    
            if (hasRipples) {
              for (let r = 0; r < activeRipples.length; r++) {
                const rip = activeRipples[r];
                const dx = centerX - rip.x;
                const dy = centerY - rip.y;
                const dist = Math.hypot(dx, dy) || 1;
    
                // Trigger square image transition as wavefront passes smoothly
                if (hasImages && rip.targetImgIndex !== undefined && dist <= rip.radius && sq.toImgIndex !== rip.targetImgIndex) {
                  sq.fromImgIndex = sq.toImgIndex;
                  sq.toImgIndex = rip.targetImgIndex;
                  sq.transitionProgress = 0;
                  sq.isTransitioning = true;
                  sq.alpha = Math.max(sq.alpha, 0.4); // Subtle, not harsh white
                }
    
                const waveDist = Math.abs(dist - rip.radius);
                if (waveDist < rip.waveWidth) {
                  // Smooth Hann cosine window: 1 at crest, 0 smoothly at bounds
                  const windowEnvelope = 0.5 * (1 + Math.cos((waveDist / rip.waveWidth) * Math.PI));
                  // Temporal attenuation
                  const temporalDecay = Math.pow(Math.max(0, 1 - rip.age / rip.lifeTime), 1.5);
                  // Radial geometric energy dispersion
                  const spatialDispersion = Math.min(1.2, 35 / Math.sqrt(Math.max(dist, 30)));
    
                  // Silky smooth sinusoidal wave height
                  const waveHeight = Math.sin((dist - rip.radius) * rip.frequency) *
                    windowEnvelope *
                    temporalDecay *
                    spatialDispersion *
                    rip.intensity;
    
                  const crestGlow = Math.max(0, waveHeight);
                  if (crestGlow > rippleAlpha) {
                    rippleAlpha = crestGlow;
                  }
    
                  // Organic, gentle liquid refraction displacement (reduced amplitude for smooth motion)
                  const fluidDisplacement = waveHeight * 4.5;
                  dispX += (dx / dist) * fluidDisplacement;
                  dispY += (dy / dist) * fluidDisplacement;
                }
              }
            }
    
            // Smooth tile crossfade progress
            if (sq.isTransitioning) {
              sq.transitionProgress += delta * 2.2;
              if (sq.transitionProgress >= 1) {
                sq.transitionProgress = 1;
                sq.isTransitioning = false;
              }
            }
    
            if (rippleAlpha > sq.alpha) {
              sq.alpha = Math.min(rippleAlpha * (hasImages ? 0.65 : 1.1), 1);
            }
    
            if (sq.alpha > 0) {
              sq.alpha = Math.max(0, sq.alpha - delta * decayRate);
            }
    
            const rx = sq.x + dispX;
            const ry = sq.y + dispY;
            displacedPos.push({ rx, ry });
    
            ctx.rect(rx + 0.5, ry + 0.5, squareSize, squareSize);
          }
    
          // Precompute image layout metrics once per frame (eliminates redundant division in inner loop)
          type ImageLayout = {
            img: HTMLImageElement;
            renderedW: number;
            renderedH: number;
            offsetX: number;
            offsetY: number;
            natW: number;
            natH: number;
          };
    
          const imgLayouts: (ImageLayout | null)[] = (hasImages && imgs.length > 0)
            ? imgs.map((img) => {
                if (img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
                  const coverScale = Math.max(rect.width / img.naturalWidth, rect.height / img.naturalHeight);
                  const renderedW = img.naturalWidth * coverScale;
                  const renderedH = img.naturalHeight * coverScale;
                  return {
                    img,
                    renderedW,
                    renderedH,
                    offsetX: (rect.width - renderedW) / 2,
                    offsetY: (rect.height - renderedH) / 2,
                    natW: img.naturalWidth,
                    natH: img.naturalHeight,
                  };
                }
                return null;
              })
            : [];
    
          // Draw Photo Image Slices if images provided
          if (hasImages && imgLayouts.length > 0) {
            for (let i = 0; i < squares.length; i++) {
              const sq = squares[i];
              const layout = imgLayouts[sq.toImgIndex % imgLayouts.length];
              if (!layout) continue;
    
              const { rx, ry } = displacedPos[i];
              const normX = Math.max(0, Math.min(1, (sq.x - layout.offsetX) / layout.renderedW));
              const normY = Math.max(0, Math.min(1, (sq.y - layout.offsetY) / layout.renderedH));
              const normW = squareSize / layout.renderedW;
              const normH = squareSize / layout.renderedH;
    
              const sx = normX * layout.natW;
              const sy = normY * layout.natH;
              const sw = Math.min(layout.natW - sx, normW * layout.natW);
              const sh = Math.min(layout.natH - sy, normH * layout.natH);
    
              if (sw > 0 && sh > 0) {
                ctx.save();
                if (sq.isTransitioning) {
                  // Silky smooth scale breathing
                  const p = sq.transitionProgress;
                  const scale = p < 0.5 ? 1 - p * 0.15 : 0.925 + (p - 0.5) * 0.15;
                  const cx = rx + squareSize / 2;
                  const cy = ry + squareSize / 2;
                  ctx.translate(cx, cy);
                  ctx.scale(scale, scale);
                  ctx.translate(-cx, -cy);
                }
                ctx.drawImage(layout.img, sx, sy, sw, sh, rx, ry, squareSize, squareSize);
                ctx.restore();
              }
            }
          }
    
          // Draw base grid with fluid displacement (subtle outline so image shines through)
          ctx.strokeStyle = hasImages ? (isDark ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.1)") : computedBorder;
          ctx.lineWidth = 1;
          ctx.stroke();
    
          // Draw lit squares: soft specular liquid shimmer instead of harsh white
          for (let i = 0; i < squares.length; i++) {
            const sq = squares[i];
            if (sq.alpha > 0.02) {
              const { rx, ry } = displacedPos[i];
              ctx.save();
              // Soft sheen (low opacity so underlying photo details stay visible)
              ctx.fillStyle = isDark
                ? `rgba(255, 255, 255, ${sq.alpha * (hasImages ? 0.16 : 0.65)})`
                : `rgba(255, 255, 255, ${sq.alpha * (hasImages ? 0.14 : 0.6)})`;
              ctx.fillRect(rx + 1, ry + 1, squareSize - 1, squareSize - 1);
    
              // Subtle liquid rim light
              ctx.strokeStyle = computedGlow;
              ctx.globalAlpha = Math.min(sq.alpha * (hasImages ? 0.45 : 1.0), 1);
              ctx.lineWidth = 1.0;
              ctx.strokeRect(rx + 0.5, ry + 0.5, squareSize, squareSize);
              ctx.restore();
            }
          }
    
          animationFrameId = requestAnimationFrame(render);
        };
    
        animationFrameId = requestAnimationFrame(render);
    
        return () => {
          cancelAnimationFrame(animationFrameId);
          resizeObserver.disconnect();
          container.removeEventListener("pointermove", onPointerMove);
          container.removeEventListener("pointerleave", onPointerLeave);
          container.removeEventListener("pointerdown", onPointerDown);
        };
      }, [
        images,
        imagesLoaded,
        squareSize,
        gap,
        borderColor,
        glowColor,
        effectiveRadius,
        decayDuration,
        baseAlpha,
      ]);
    
      return (
        <div
          ref={containerRef}
          className={cn(
            "relative w-full h-full min-h-[16rem] overflow-hidden bg-canvas select-none cursor-crosshair active:cursor-grabbing",
            className
          )}
        >
          <canvas
            ref={canvasRef}
            className="absolute inset-0 pointer-events-none block w-full h-full"
          />
          {/* Subtle radial vignette gradient for a premium edge fade */}
          <div
            className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,transparent_50%,var(--color-canvas,#050505)_100%)]"
            aria-hidden="true"
          />
          {children && <div className="relative z-10 w-full h-full">{children}</div>}
        </div>
      );
    }

Props

PropTypeDefaultDescription
squareSizenumber14Square dimensions in pixels (width and height).
gapnumber3Gap between adjacent squares in pixels.
borderColorstring"rgba(255, 255, 255, 0.18)"Idle border outline color for squares.
glowColorstring"rgba(255, 255, 255, 0.96)"Highlight color when a square is hovered and illuminated.
hoverRadiusnumber45Cursor brush trail radius in pixels.
decayDurationnumber1.1Duration in seconds for the illuminated trail to smoothly decay.
imagesstring[]—Optional image URLs to render into pixel squares and transition between on click.
baseAlphanumber0.04Subtle base opacity of idle square tiles.
classNamestring—Optional container class name overrides.
childrenReact.ReactNode—Optional foreground content overlay.