satoriui
componentkinetic-gridsource: ok · hit
Kinetic Grid
Kinetic grid animation.
npx shadcn add "https://satoriui.site/r/kinetic-grid.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files1 · source cached 10/9/2026, 4:15:14 AM
packages/kinetic-grid/kinetic-grid.tsx
"use client";
import { useEffect, useRef, useCallback, ReactNode } from "react";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Point {
x: number;
y: number;
}
interface Ripple {
x: number;
y: number;
radius: number;
opacity: number;
born: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const CELL_SIZE = 55; // Desktop-ish size. Will dictate cols/rows
const INFLUENCE_RADIUS = 260;
const MAX_WARP = 24;
const DOT_SPACING = 28;
const LERP_SPEED = 0.08;
const LINE_BASE = { r: 255, g: 255, b: 255, a: 0.13 };
const NODE_BASE_RADIUS = 1.8;
const NODE_ACTIVE_RADIUS = 3.2;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function lerpN(a: number, b: number, t: number) {
return a + (b - a) * t;
}
function lerpColor(
base: { r: number; g: number; b: number; a: number },
active: { r: number; g: number; b: number; a: number },
t: number
): string {
const r = Math.round(lerpN(base.r, active.r, t));
const g = Math.round(lerpN(base.g, active.g, t));
const b = Math.round(lerpN(base.b, active.b, t));
const a = lerpN(base.a, active.a, t);
return `rgba(${r},${g},${b},${a.toFixed(3)})`;
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function KineticGrid({
children,
className,
globalColor = "default",
}: {
children?: ReactNode;
className?: string;
globalColor?: "default" | "monochrome";
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const mouseRef = useRef<Point>({ x: -9999, y: -9999 });
const targetMouseRef = useRef<Point>({ x: -9999, y: -9999 });
const ripplesRef = useRef<Ripple[]>([]);
const rafRef = useRef<number>(0);
const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
// ── Warp ────────────────────────────────────────────────────────────────────
const getWarpedPoint = useCallback(
(
gx: number,
gy: number,
col: number,
row: number,
mouse: Point,
ripples: Ripple[],
cols: number,
rows: number
): { pt: Point; proximity: number } => {
// Edge pin — smoothly locks boundary rows/cols in place
const edgeMargin = 1.5;
const colPin = Math.min(col / edgeMargin, (cols - 1 - col) / edgeMargin, 1);
const rowPin = Math.min(row / edgeMargin, (rows - 1 - row) / edgeMargin, 1);
const pinFactor = colPin * colPin * rowPin * rowPin;
const dx = gx - mouse.x;
const dy = gy - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const proximity = Math.max(0, 1 - dist / INFLUENCE_RADIUS) * pinFactor;
// Ripple displacement
let rx = 0, ry = 0;
for (const r of ripples) {
const rdx = gx - r.x;
const rdy = gy - r.y;
const rdist = Math.sqrt(rdx * rdx + rdy * rdy);
const waveWidth = 55;
const diff = rdist - r.radius;
if (Math.abs(diff) < waveWidth) {
const strength = (1 - Math.abs(diff) / waveWidth) * r.opacity * 18 * pinFactor;
const angle = Math.atan2(rdy, rdx);
const sign = diff < 0 ? -1 : 1;
rx += Math.cos(angle) * strength * sign * -1;
ry += Math.sin(angle) * strength * sign * -1;
}
}
// Cursor warp with bell falloff
if (dist < INFLUENCE_RADIUS && dist > 0 && pinFactor > 0) {
const t = dist / INFLUENCE_RADIUS;
const eased = t < 0.01 ? 0 : (1 - t) * (1 - t) * Math.min(1, dist / 60);
const warpAmt = eased * MAX_WARP * pinFactor;
const angle = Math.atan2(dy, dx);
return {
pt: {
x: gx - Math.cos(angle) * warpAmt + rx,
y: gy - Math.sin(angle) * warpAmt + ry,
},
proximity,
};
}
return { pt: { x: gx + rx, y: gy + ry }, proximity };
},
[]
);
// ── Draw ────────────────────────────────────────────────────────────────────
const draw = useCallback(
(now: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { w: W, h: H } = sizeRef.current;
const mouse = mouseRef.current;
const ripples = ripplesRef.current;
const theme = {
default: {
bg: "#161618",
lineActive: { r: 74, g: 158, b: 255, a: 0.90 },
nodeActive: { r: 74, g: 158, b: 255, a: 1.00 },
glow: "74,158,255",
ripple: "100,180,255",
},
monochrome: {
bg: "#000000",
lineActive: { r: 255, g: 255, b: 255, a: 0.90 },
nodeActive: { r: 255, g: 255, b: 255, a: 1.00 },
glow: "255,255,255",
ripple: "255,255,255",
}
}[globalColor ?? "default"];
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = theme.bg;
ctx.fillRect(0, 0, W, H);
// Static background dot texture
ctx.fillStyle = "rgba(255,255,255,0.05)";
for (let x = DOT_SPACING / 2; x < W; x += DOT_SPACING) {
for (let y = DOT_SPACING / 2; y < H; y += DOT_SPACING) {
ctx.beginPath();
ctx.arc(x, y, 0.7, 0, Math.PI * 2);
ctx.fill();
}
}
// Update ripples
for (let i = ripples.length - 1; i >= 0; i--) {
const r = ripples[i];
const age = (now - r.born) / 1000;
// FIX: Ensure radius is never negative
r.radius = Math.max(0, age * 400);
r.opacity = Math.max(0, 1 - age * 1.2);
if (r.opacity <= 0) ripples.splice(i, 1);
}
// ── Build warped grid ─────────────────────────────────────────────────
const cols = Math.max(2, Math.ceil(W / CELL_SIZE)) + 1;
const rows = Math.max(2, Math.ceil(H / CELL_SIZE)) + 1;
const cellW = W / (cols - 1);
const cellH = H / (rows - 1);
const pts: Point[][] = [];
const prox: number[][] = [];
for (let row = 0; row < rows; row++) {
pts[row] = [];
prox[row] = [];
for (let col = 0; col < cols; col++) {
const { pt, proximity } = getWarpedPoint(
col * cellW, row * cellH, col, row, mouse, ripples, cols, rows
);
pts[row][col] = pt;
prox[row][col] = proximity;
}
}
// ── Grid lines ────────────────────────────────────────────────────────
const drawSeg = (p1: Point, p2: Point, pr1: number, pr2: number) => {
const avg = (pr1 + pr2) / 2;
const t = avg * avg * (3 - 2 * avg); // smoothstep
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = lerpColor(LINE_BASE, theme.lineActive, t);
ctx.lineWidth = lerpN(0.8, 1.5, t);
ctx.stroke();
};
ctx.lineCap = "butt";
for (let row = 0; row < rows; row++)
for (let col = 0; col < cols - 1; col++)
drawSeg(pts[row][col], pts[row][col + 1], prox[row][col], prox[row][col + 1]);
for (let col = 0; col < cols; col++)
for (let row = 0; row < rows - 1; row++)
drawSeg(pts[row][col], pts[row + 1][col], prox[row][col], prox[row + 1][col]);
// ── Intersection nodes ────────────────────────────────────────────────
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const p = pts[row][col];
const pr = prox[row][col];
const t = pr * pr * (3 - 2 * pr); // smoothstep
const r = lerpN(NODE_BASE_RADIUS, NODE_ACTIVE_RADIUS, t);
// Outer glow ring for active nodes
if (t > 0.3) {
const glowR = r + lerpN(0, 6, (t - 0.3) / 0.7);
const grd = ctx.createRadialGradient(p.x, p.y, r * 0.5, p.x, p.y, glowR);
grd.addColorStop(0, `rgba(${theme.glow},${(t * 0.30).toFixed(3)})`);
grd.addColorStop(1, `rgba(${theme.glow},0)`);
ctx.beginPath();
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.fill();
}
// Node fill
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = lerpColor(
{ r: 255, g: 255, b: 255, a: 0.20 },
theme.nodeActive,
t
);
ctx.fill();
}
}
// ── Ripple rings ──────────────────────────────────────────────────────
for (const r of ripples) {
// FIX: Ensure radius is positive before drawing arc
const safeRadius = Math.max(0, r.radius);
ctx.beginPath();
ctx.arc(r.x, r.y, safeRadius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${theme.ripple},${(r.opacity * 0.28).toFixed(3)})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
},
[getWarpedPoint, globalColor]
);
// ── Animation loop ──────────────────────────────────────────────────────────
const animate = useCallback(
(now: number) => {
const m = mouseRef.current;
const t = targetMouseRef.current;
m.x = lerpN(m.x, t.x, LERP_SPEED);
m.y = lerpN(m.y, t.y, LERP_SPEED);
draw(now);
rafRef.current = requestAnimationFrame(animate);
},
[draw]
);
// ── Setup ───────────────────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const setSize = () => {
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = w;
canvas.height = h;
sizeRef.current = { w, h };
if (mouseRef.current.x === -9999) {
mouseRef.current = { x: -9999, y: -9999 };
targetMouseRef.current = { x: -9999, y: -9999 };
}
};
setSize();
window.addEventListener("resize", setSize);
const onMouseMove = (e: MouseEvent) => {
targetMouseRef.current = { x: e.clientX, y: e.clientY };
};
const onClick = (e: MouseEvent) => {
ripplesRef.current.push({
x: e.clientX, y: e.clientY,
radius: 0, opacity: 1,
born: performance.now(),
});
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("click", onClick);
rafRef.current = requestAnimationFrame(animate);
return () => {
window.removeEventListener("resize", setSize);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("click", onClick);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [animate]);
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className={cn("relative w-full min-h-screen overflow-hidden", globalColor === "monochrome" ? "bg-[#000000]" : "bg-[#161618]", className)}>
<canvas ref={canvasRef} className="fixed inset-0 w-full h-full z-0 pointer-events-none" />
<div className="relative z-10 w-full h-full">
{children}
</div>
</div>
);
}