ShapeGrid
Animated grid with shape variants (square, hexagon, circle, triangle) + direction customization.
Live preview
Loading demo…
Installation
npx shadcn@latest add https://reactbits.dev/r/ShapeGrid-TS-TW.json
Source
'use client';
import React, { useRef, useEffect } from 'react';
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
interface GridOffset {
x: number;
y: number;
}
interface ShapeGridProps {
direction?: 'diagonal' | 'up' | 'right' | 'down' | 'left';
speed?: number;
borderColor?: CanvasStrokeStyle;
squareSize?: number;
hoverFillColor?: CanvasStrokeStyle;
shape?: 'square' | 'hexagon' | 'circle' | 'triangle';
hoverTrailAmount?: number;
}
const ShapeGrid: React.FC<ShapeGridProps> = ({
direction = 'right',
speed = 1,
borderColor = '#999',
squareSize = 40,
hoverFillColor = '#222',
shape = 'square',
hoverTrailAmount = 0
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const requestRef = useRef<number | null>(null);
const numSquaresX = useRef<number>(0);
const numSquaresY = useRef<number>(0);
const gridOffset = useRef<GridOffset>({ x: 0, y: 0 });
const hoveredSquareRef = useRef<GridOffset | null>(null);
const trailCells = useRef<GridOffset[]>([]);
const cellOpacities = useRef<Map<string, number>>(new Map());
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const isHex = shape === 'hexagon';
const isTri = shape === 'triangle';
const hexHoriz = squareSize * 1.5;
const hexVert = squareSize * Math.sqrt(3);
const resizeCanvas = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
numSquaresX.current = Math.ceil(canvas.width / squareSize) + 1;
numSquaresY.current = Math.ceil(canvas.height / squareSize) + 1;
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const drawHex = (cx: number, cy: number, size: number) => {
if (!ctx) return;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i;
const vx = cx + size * Math.cos(angle);
const vy = cy + size * Math.sin(angle);
if (i === 0) ctx.moveTo(vx, vy);
else ctx.lineTo(vx, vy);
}
ctx.closePath();
};
const drawCircle = (cx: number, cy: number, size: number) => {
if (!ctx) return;
ctx.beginPath();
ctx.arc(cx, cy, size / 2, 0, Math.PI * 2);
ctx.closePath();
};
const drawTriangle = (cx: number, cy: number, size: number, flip: boolean) => {
if (!ctx) return;
ctx.beginPath();
if (flip) {
ctx.moveTo(cx, cy + size / 2);
ctx.lineTo(cx + size / 2, cy - size / 2);
ctx.lineTo(cx - size / 2, cy - size / 2);
} else {
ctx.moveTo(cx, cy - size / 2);
ctx.lineTo(cx + size / 2, cy + size / 2);
ctx.lineTo(cx - size / 2, cy + size / 2);
}
ctx.closePath();
};
const drawGrid = () => {
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (isHex) {
const colShift = Math.floor(gridOffset.current.x / hexHoriz);
const offsetX = ((gridOffset.current.x % hexHoriz) + hexHoriz) % hexHoriz;
const offsetY = ((gridOffset.current.y % hexVert) + hexVert) % hexVert;
const cols = Math.ceil(canvas.width / hexHoriz) + 3;
const rows = Math.ceil(canvas.height / hexVert) + 3;
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * hexHoriz + offsetX;
const cy = row * hexVert + ((col + colShift) % 2 !== 0 ? hexVert / 2 : 0) + offsetY;
const cellKey = `${col},${row}`;
const alpha = cellOpacities.current.get(cellKey);
if (alpha) {
ctx.globalAlpha = alpha;
drawHex(cx, cy, squareSize);
ctx.fillStyle = hoverFillColor;
ctx.fill();
ctx.globalAlpha = 1;
}
drawHex(cx, cy, squareSize);
ctx.strokeStyle = borderColor;
ctx.stroke();
}
}
} else if (isTri) {
const halfW = squareSize / 2;
const colShift = Math.floor(gridOffset.current.x / halfW);
const rowShift = Math.floor(gridOffset.current.y / squareSize);
const offsetX = ((gridOffset.current.x % halfW) + halfW) % halfW;
const offsetY = ((gridOffset.current.y % squareSize) + squareSize) % squareSize;
const cols = Math.ceil(canvas.width / halfW) + 4;
const rows = Math.ceil(canvas.height / squareSize) + 4;
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * halfW + offsetX;
const cy = row * squareSize + squareSize / 2 + offsetY;
const flip = ((col + colShift + row + rowShift) % 2 + 2) % 2 !== 0;
const cellKey = `${col},${row}`;
const alpha = cellOpacities.current.get(cellKey);
if (alpha) {
ctx.globalAlpha = alpha;
drawTriangle(cx, cy, squareSize, flip);
ctx.fillStyle = hoverFillColor;
ctx.fill();
ctx.globalAlpha = 1;
}
drawTriangle(cx, cy, squareSize, flip);
ctx.strokeStyle = borderColor;
ctx.stroke();
}
}
} else if (shape === 'circle') {
const offsetX = ((gridOffset.current.x % squareSize) + squareSize) % squareSize;
const offsetY = ((gridOffset.current.y % squareSize) + squareSize) % squareSize;
const cols = Math.ceil(canvas.width / squareSize) + 3;
const rows = Math.ceil(canvas.height / squareSize) + 3;
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * squareSize + squareSize / 2 + offsetX;
const cy = row * squareSize + squareSize / 2 + offsetY;
const cellKey = `${col},${row}`;
const alpha = cellOpacities.current.get(cellKey);
if (alpha) {
ctx.globalAlpha = alpha;
drawCircle(cx, cy, squareSize);
ctx.fillStyle = hoverFillColor;
ctx.fill();
ctx.globalAlpha = 1;
}
drawCircle(cx, cy, squareSize);
ctx.strokeStyle = borderColor;
ctx.stroke();
}
}
} else {
const offsetX = ((gridOffset.current.x % squareSize) + squareSize) % squareSize;
const offsetY = ((gridOffset.current.y % squareSize) + squareSize) % squareSize;
const cols = Math.ceil(canvas.width / squareSize) + 3;
const rows = Math.ceil(canvas.height / squareSize) + 3;
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const sx = col * squareSize + offsetX;
const sy = row * squareSize + offsetY;
const cellKey = `${col},${row}`;
const alpha = cellOpacities.current.get(cellKey);
if (alpha) {
ctx.globalAlpha = alpha;
ctx.fillStyle = hoverFillColor;
ctx.fillRect(sx, sy, squareSize, squareSize);
ctx.globalAlpha = 1;
}
ctx.strokeStyle = borderColor;
ctx.strokeRect(sx, sy, squareSize, squareSize);
}
}
}
const gradient = ctx.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2
);
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
gradient.addColorStop(1, '#120F17');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const updateAnimation = () => {
const effectiveSpeed = Math.max(speed, 0.1);
const wrapX = isHex ? hexHoriz * 2 : squareSize;
const wrapY = isHex ? hexVert : isTri ? squareSize * 2 : squareSize;
switch (direction) {
case 'right':
gridOffset.current.x = (gridOffset.current.x - effectiveSpeed + wrapX) % wrapX;
break;
case 'left':
gridOffset.current.x = (gridOffset.current.x + effectiveSpeed + wrapX) % wrapX;
break;
case 'up':
gridOffset.current.y = (gridOffset.current.y + effectiveSpeed + wrapY) % wrapY;
break;
case 'down':
gridOffset.current.y = (gridOffset.current.y - effectiveSpeed + wrapY) % wrapY;
break;
case 'diagonal':
gridOffset.current.x = (gridOffset.current.x - effectiveSpeed + wrapX) % wrapX;
gridOffset.current.y = (gridOffset.current.y - effectiveSpeed + wrapY) % wrapY;
break;
default:
break;
}
updateCellOpacities();
drawGrid();
requestRef.current = requestAnimationFrame(updateAnimation);
};
const updateCellOpacities = () => {
const targets = new Map<string, number>();
if (hoveredSquareRef.current) {
targets.set(`${hoveredSquareRef.current.x},${hoveredSquareRef.current.y}`, 1);
}
if (hoverTrailAmount > 0) {
for (let i = 0; i < trailCells.current.length; i++) {
const t = trailCells.current[i];
const key = `${t.x},${t.y}`;
if (!targets.has(key)) {
targets.set(key, (trailCells.current.length - i) / (trailCells.current.length + 1));
}
}
}
for (const [key] of targets) {
if (!cellOpacities.current.has(key)) {
cellOpacities.current.set(key, 0);
}
}
for (const [key, opacity] of cellOpacities.current) {
const target = targets.get(key) || 0;
const next = opacity + (target - opacity) * 0.15;
if (next < 0.005) {
cellOpacities.current.delete(key);
} else {
cellOpacities.current.set(key, next);
}
}
};
const handleMouseMove = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
if (isHex) {
const colShift = Math.floor(gridOffset.current.x / hexHoriz);
const offsetX = ((gridOffset.current.x % hexHoriz) + hexHoriz) % hexHoriz;
const offsetY = ((gridOffset.current.y % hexVert) + hexVert) % hexVert;
const adjustedX = mouseX - offsetX;
const adjustedY = mouseY - offsetY;
const col = Math.round(adjustedX / hexHoriz);
const rowOffset = (col + colShift) % 2 !== 0 ? hexVert / 2 : 0;
const row = Math.round((adjustedY - rowOffset) / hexVert);
if (
!hoveredSquareRef.current ||
hoveredSquareRef.current.x !== col ||
hoveredSquareRef.current.y !== row
) {
if (hoveredSquareRef.current && hoverTrailAmount > 0) {
trailCells.current.unshift({ ...hoveredSquareRef.current });
if (trailCells.current.length > hoverTrailAmount) trailCells.current.length = hoverTrailAmount;
}
hoveredSquareRef.current = { x: col, y: row };
}
} else if (isTri) {
const halfW = squareSize / 2;
const offsetX = ((gridOffset.current.x % halfW) + halfW) % halfW;
const offsetY = ((gridOffset.current.y % squareSize) + squareSize) % squareSize;
const adjustedX = mouseX - offsetX;
const adjustedY = mouseY - offsetY;
const col = Math.round(adjustedX / halfW);
const row = Math.floor(adjustedY / squareSize);
if (
!hoveredSquareRef.current ||
hoveredSquareRef.current.x !== col ||
hoveredSquareRef.current.y !== row
) {
if (hoveredSquareRef.current && hoverTrailAmount > 0) {
trailCells.current.unshift({ ...hoveredSquareRef.current });
if (trailCells.current.length > hoverTrailAmount) trailCells.current.length = hoverTrailAmount;
}
hoveredSquareRef.current = { x: col, y: row };
}
} else if (shape === 'circle') {
const offsetX = ((gridOffset.current.x % squareSize) + squareSize) % squareSize;
const offsetY = ((gridOffset.current.y % squareSize) + squareSize) % squareSize;
const adjustedX = mouseX - offsetX;
const adjustedY = mouseY - offsetY;
const col = Math.round(adjustedX / squareSize);
const row = Math.round(adjustedY / squareSize);
if (
!hoveredSquareRef.current ||
hoveredSquareRef.current.x !== col ||
hoveredSquareRef.current.y !== row
) {
if (hoveredSquareRef.current && hoverTrailAmount > 0) {
trailCells.current.unshift({ ...hoveredSquareRef.current });
if (trailCells.current.length > hoverTrailAmount) trailCells.current.length = hoverTrailAmount;
}
hoveredSquareRef.current = { x: col, y: row };
}
} else {
const offsetX = ((gridOffset.current.x % squareSize) + squareSize) % squareSize;
const offsetY = ((gridOffset.current.y % squareSize) + squareSize) % squareSize;
const adjustedX = mouseX - offsetX;
const adjustedY = mouseY - offsetY;
const col = Math.floor(adjustedX / squareSize);
const row = Math.floor(adjustedY / squareSize);
if (
!hoveredSquareRef.current ||
hoveredSquareRef.current.x !== col ||
hoveredSquareRef.current.y !== row
) {
if (hoveredSquareRef.current && hoverTrailAmount > 0) {
trailCells.current.unshift({ ...hoveredSquareRef.current });
if (trailCells.current.length > hoverTrailAmount) trailCells.current.length = hoverTrailAmount;
}
hoveredSquareRef.current = { x: col, y: row };
}
}
};
const handleMouseLeave = () => {
if (hoveredSquareRef.current && hoverTrailAmount > 0) {
trailCells.current.unshift({ ...hoveredSquareRef.current });
if (trailCells.current.length > hoverTrailAmount) trailCells.current.length = hoverTrailAmount;
}
hoveredSquareRef.current = null;
};
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
let isVisible = false;
let isPageVisible = !document.hidden;
const tryStart = () => {
if (isVisible && isPageVisible && !requestRef.current) {
requestRef.current = requestAnimationFrame(updateAnimation);
}
};
const tryStop = () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}
};
const io = new IntersectionObserver(
([entry]) => {
isVisible = entry.isIntersecting;
isVisible ? tryStart() : tryStop();
},
{ threshold: 0 }
);
io.observe(canvas);
const onVisibility = () => {
isPageVisible = !document.hidden;
isPageVisible ? tryStart() : tryStop();
};
document.addEventListener('visibilitychange', onVisibility);
tryStart();
return () => {
window.removeEventListener('resize', resizeCanvas);
tryStop();
io.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
};
}, [direction, speed, borderColor, hoverFillColor, squareSize, shape, hoverTrailAmount]);
return <canvas ref={canvasRef} className="w-full h-full border-none block"></canvas>;
};
export default ShapeGrid;