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.
import { BlinkingSquares } from "@/components/ui/blinking-squares";
const userImages = [
"/images/transitions/photo-1.jpg",
"/images/transitions/photo-2.jpg",
"/images/transitions/photo-3.jpg",
];
export default function BlinkingSquaresDemo({ fullscreen }: { fullscreen?: boolean }) {
return (
<div className={fullscreen ? "relative w-full h-screen min-h-screen" : "relative w-full h-full min-h-[26rem] sm:min-h-[30rem]"}>
<BlinkingSquares
images={userImages}
squareSize={16}
gap={2}
hoverRadius={55}
decayDuration={1.1}
className={fullscreen ? "h-screen min-h-screen" : "h-full min-h-[26rem] sm:min-h-[30rem]"}
/>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install clsx tailwind-merge2. Add the
cnhelper, 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. 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
| Prop | Type | Default | Description |
|---|---|---|---|
| squareSize | number | 14 | Square dimensions in pixels (width and height). |
| gap | number | 3 | Gap between adjacent squares in pixels. |
| borderColor | string | "rgba(255, 255, 255, 0.18)" | Idle border outline color for squares. |
| glowColor | string | "rgba(255, 255, 255, 0.96)" | Highlight color when a square is hovered and illuminated. |
| hoverRadius | number | 45 | Cursor brush trail radius in pixels. |
| decayDuration | number | 1.1 | Duration in seconds for the illuminated trail to smoothly decay. |
| images | string[] | — | Optional image URLs to render into pixel squares and transition between on click. |
| baseAlpha | number | 0.04 | Subtle base opacity of idle square tiles. |
| className | string | — | Optional container class name overrides. |
| children | React.ReactNode | — | Optional foreground content overlay. |