Skip to content

Instantly share code, notes, and snippets.

@JohnPhamous
Created August 21, 2025 17:38
Show Gist options
  • Save JohnPhamous/21678a87c37a8c60aa1726b10f4869ec to your computer and use it in GitHub Desktop.
Save JohnPhamous/21678a87c37a8c60aa1726b10f4869ec to your computer and use it in GitHub Desktop.
"use client";
import { ColorPalette } from "@/types/colors";
import React, { useCallback, useEffect, useRef, useState } from "react";
interface DotPatternBackgroundProps {
dotSize?: number;
gridGap?: number;
flickerChance?: number;
colorVariable: ColorPalette;
width?: number;
height?: number;
className?: string;
maxOpacity?: number;
}
export const DotPatternBackground: React.FC<DotPatternBackgroundProps> = ({
dotSize = 4,
gridGap = 6,
flickerChance = 0.3,
colorVariable = "--color-blue-10",
width,
height,
className,
maxOpacity = 1,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const setupCanvas = useCallback(
(canvas: HTMLCanvasElement, width: number, height: number) => {
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// Scale the context to ensure crisp rendering on retina screens
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.scale(dpr, dpr);
}
// Treat dotSize and gridGap as targets. Compute tiling that perfectly fits
// the container by adjusting actual sizes to the nearest feasible grid.
const targetPitch = Math.max(1, dotSize + gridGap);
const targetDot = Math.max(0, dotSize);
const targetGap = Math.max(0, gridGap);
const hasGap = targetGap > 0;
const ratio = hasGap ? targetDot / targetGap : Infinity;
// Helper to evaluate best count for axis to keep close to target pitch with padding = gap
const pickBestCount = (total: number) => {
const approx = Math.max(1, (total + targetGap) / targetPitch);
const candidates = [Math.max(1, Math.floor(approx)), Math.max(1, Math.ceil(approx))];
let best = candidates[0];
let bestScore = Number.POSITIVE_INFINITY;
for (const c of candidates) {
const g = hasGap ? total / (c * ratio + c + 1) : 0;
const d = hasGap ? ratio * g : total / c;
const pitch = d + g;
const score = Math.abs(pitch - targetPitch) + Math.abs(d - targetDot) * 0.5 + Math.abs(g - targetGap) * 0.5;
if (score < bestScore) {
bestScore = score;
best = c;
}
}
return best;
};
const cols = pickBestCount(width);
const rows = pickBestCount(height);
// Solve exact sizes per axis with padding = gap
const gapX = hasGap ? width / (cols * ratio + cols + 1) : 0;
const dotX = hasGap ? ratio * gapX : width / cols;
const gapY = hasGap ? height / (rows * ratio + rows + 1) : 0;
const dotY = hasGap ? ratio * gapY : height / rows;
const radiusX = dotX / 2;
const radiusY = dotY / 2;
const squares = new Float32Array(cols * rows);
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity;
}
return {
cols,
rows,
squares,
dpr,
radiusX,
radiusY,
gapX,
gapY,
};
},
[dotSize, gridGap, maxOpacity],
);
const updateSquares = useCallback(
(squares: Float32Array, deltaTime: number) => {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance * deltaTime) {
squares[i] = Math.random() * maxOpacity;
}
}
},
[flickerChance, maxOpacity],
);
const drawGrid = useCallback(
(
ctx: CanvasRenderingContext2D,
logicalWidth: number,
logicalHeight: number,
params: {
cols: number;
rows: number;
squares: Float32Array;
radiusX: number;
radiusY: number;
gapX: number;
gapY: number;
},
) => {
// Clear using logical dimensions since context is already scaled
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
const color = getComputedStyle(ctx.canvas).getPropertyValue(colorVariable);
ctx.fillStyle = color;
// Disable image smoothing for crisp pixel-perfect rendering
ctx.imageSmoothingEnabled = false;
const { cols, rows, squares, radiusX, radiusY, gapX, gapY } = params;
const startX = gapX + radiusX;
const startY = gapY + radiusY;
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const opacity = squares[i * rows + j];
ctx.globalAlpha = opacity;
// Centers with equal padding = gap on both sides
const x = Math.round(startX + i * (radiusX * 2 + gapX));
const y = Math.round(startY + j * (radiusY * 2 + gapY));
ctx.beginPath();
ctx.ellipse(x, y, radiusX, radiusY, 0, 0, 2 * Math.PI);
ctx.fill();
}
}
// Reset globalAlpha
ctx.globalAlpha = 1;
},
[colorVariable],
);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animationFrameId: number;
let gridParams: ReturnType<typeof setupCanvas>;
const updateCanvasSize = () => {
const newWidth = width || container.clientWidth;
const newHeight = height || container.clientHeight;
setCanvasSize({ width: newWidth, height: newHeight });
gridParams = setupCanvas(canvas, newWidth, newHeight);
};
updateCanvasSize();
let lastTime = 0;
const animate = (time: number) => {
if (!isInView) return;
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
updateSquares(gridParams.squares, deltaTime);
drawGrid(ctx, canvasSize.width, canvasSize.height, {
cols: gridParams.cols,
rows: gridParams.rows,
squares: gridParams.squares,
radiusX: gridParams.radiusX,
radiusY: gridParams.radiusY,
gapX: gridParams.gapX,
gapY: gridParams.gapY,
});
animationFrameId = requestAnimationFrame(animate);
};
const resizeObserver = new ResizeObserver(() => {
updateCanvasSize();
});
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting);
},
{ threshold: 0 },
);
intersectionObserver.observe(canvas);
if (isInView) {
animationFrameId = requestAnimationFrame(animate);
}
return () => {
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView, canvasSize.width, canvasSize.height]);
return (
<div ref={containerRef} className={`w-full h-full ${className}`} aria-hidden>
<canvas
ref={canvasRef}
className="pointer-events-none"
style={{
width: canvasSize.width,
height: canvasSize.height,
}}
/>
</div>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment