Kinetic Particle Fabric
Kinetic Particle Fabric is a React and Tailwind CSS effect component written for NedDev and yours to ship. Copy it and paste it into your project.
NedDev
effects
What it is
Kinetic Particle Fabric is a React and Tailwind CSS effect component written for NedDev and yours to ship. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Four free copies a week. No card.
Needs
npm i lucide-react
Licence
This component was written for this catalogue. It is yours to ship in client work and in products you sell, under the terms of use.
Kinetic Particle Fabric
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { Play, Pause, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
interface Vector3 {
x: number;
y: number;
z: number;
}
interface PhysicsNode {
curr: Vector3;
prev: Vector3;
base: Vector3;
proj: { x: number; y: number; scale: number; alpha: number };
pinned: boolean;
excitation: number;
}
interface StructuralConstraint {
p1: number;
p2: number;
length: number;
}
export interface KineticFabricProps {
headline?: string;
tagline?: string;
className?: string;
}
export function KineticFabric({
headline = "TOPOLOGY",
tagline = "TENSOR",
className = "",
}: KineticFabricProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [isRunning, setIsRunning] = useState(true);
const pointerRef = useRef({
x: -2000,
y: -2000,
prevX: -2000,
prevY: -2000,
vx: 0,
vy: 0,
targetAngleX: 0.15,
targetAngleY: 0.0,
angleX: 0.15,
angleY: 0.0,
radius: 190,
isDown: false,
shockwaves: [] as { x: number; y: number; radius: number; maxRadius: number; strength: number }[],
});
const nodesRef = useRef<PhysicsNode[]>([]);
const linksRef = useRef<StructuralConstraint[]>([]);
const dimensionsRef = useRef({ width: 0, height: 0 });
const buildMesh = useCallback(() => {
const { width, height } = dimensionsRef.current;
if (width === 0 || height === 0) return;
const spacing = 38;
const cols = Math.ceil((width * 1.2) / spacing) + 1;
const rows = Math.ceil((height * 1.2) / spacing) + 1;
const nodes: PhysicsNode[] = [];
const links: StructuralConstraint[] = [];
const grid: number[][] = [];
const startX = -(cols * spacing) / 2;
const startY = -(rows * spacing) / 2;
let index = 0;
for (let j = 0; j < rows; j++) {
grid[j] = [];
for (let i = 0; i < cols; i++) {
const bx = startX + i * spacing;
const by = startY + j * spacing;
const bz = 0;
const isPinned = i === 0 || i === cols - 1 || j === 0 || j === rows - 1;
nodes.push({
curr: { x: bx, y: by, z: bz },
prev: { x: bx, y: by, z: bz },
base: { x: bx, y: by, z: bz },
proj: { x: 0, y: 0, scale: 1, alpha: 1 },
pinned: isPinned,
excitation: 0,
});
grid[j][i] = index++;
}
}
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
const currIdx = grid[j][i];
if (i < cols - 1) {
links.push({ p1: currIdx, p2: grid[j][i + 1], length: spacing });
}
if (j < rows - 1) {
links.push({ p1: currIdx, p2: grid[j + 1][i], length: spacing });
}
if (i < cols - 1 && j < rows - 1) {
links.push({ p1: currIdx, p2: grid[j + 1][i + 1], length: Math.SQRT2 * spacing });
}
}
}
nodesRef.current = nodes;
linksRef.current = links;
}, []);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const rect = entry.contentRect;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
dimensionsRef.current = { width: rect.width, height: rect.height };
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);
buildMesh();
}
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [buildMesh]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
let animId = 0;
let time = 0;
const loop = () => {
if (!isRunning) {
animId = requestAnimationFrame(loop);
return;
}
time += 0.016;
const { width, height } = dimensionsRef.current;
const nodes = nodesRef.current;
const links = linksRef.current;
const pointer = pointerRef.current;
pointer.vx = (pointer.x - pointer.prevX) * 0.4;
pointer.vy = (pointer.y - pointer.prevY) * 0.4;
pointer.prevX = pointer.x;
pointer.prevY = pointer.y;
const pointerSpeed = Math.min(Math.sqrt(pointer.vx * pointer.vx + pointer.vy * pointer.vy), 40);
pointer.angleX += (pointer.targetAngleX - pointer.angleX) * 0.05;
pointer.angleY += (pointer.targetAngleY - pointer.angleY) * 0.05;
const cosX = Math.cos(pointer.angleX);
const sinX = Math.sin(pointer.angleX);
const cosY = Math.cos(pointer.angleY);
const sinY = Math.sin(pointer.angleY);
const isDark = document.documentElement.classList.contains("dark");
const bgColor = isDark ? "#09090b" : "#fafafa";
const strokeColor = isDark ? "255, 255, 255" : "0, 0, 0";
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
for (let s = pointer.shockwaves.length - 1; s >= 0; s--) {
const sw = pointer.shockwaves[s];
sw.radius += 12;
sw.strength *= 0.94;
if (sw.radius > sw.maxRadius || sw.strength < 0.01) {
pointer.shockwaves.splice(s, 1);
}
}
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (n.pinned) continue;
const vx = (n.curr.x - n.prev.x) * 0.955;
const vy = (n.curr.y - n.prev.y) * 0.955;
const vz = (n.curr.z - n.prev.z) * 0.955;
n.prev.x = n.curr.x;
n.prev.y = n.curr.y;
n.prev.z = n.curr.z;
n.curr.x += vx;
n.curr.y += vy;
n.curr.z += vz;
const fluidZ =
Math.sin(n.base.x * 0.009 + time) * 16 +
Math.cos(n.base.y * 0.011 + time * 1.2) * 12;
n.curr.x += (n.base.x - n.curr.x) * 0.038;
n.curr.y += (n.base.y - n.curr.y) * 0.038;
n.curr.z += (n.base.z + fluidZ - n.curr.z) * 0.038;
n.excitation *= 0.92;
}
const fov = 620;
const cx = width / 2;
const cy = height / 2;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const rx1 = n.curr.x * cosY + n.curr.z * sinY;
const ry1 = n.curr.y;
const rz1 = -n.curr.x * sinY + n.curr.z * cosY;
const rx2 = rx1;
const ry2 = ry1 * cosX - rz1 * sinX;
const rz2 = ry1 * sinX + rz1 * cosX + 460;
const scale = fov / Math.max(1, rz2);
n.proj.x = cx + rx2 * scale;
n.proj.y = cy + ry2 * scale;
n.proj.scale = scale;
n.proj.alpha = Math.min(1, Math.max(0.08, (scale - 0.45) * 1.4));
if (!n.pinned) {
const dx = n.proj.x - pointer.x;
const dy = n.proj.y - pointer.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < pointer.radius && dist > 0) {
const ratio = 1 - dist / pointer.radius;
const force = ratio * (pointer.isDown ? 42 : 22) + pointerSpeed * 0.4;
const angle = Math.atan2(dy, dx);
n.curr.x += (Math.cos(angle) * force * 0.8) / n.proj.scale;
n.curr.y += (Math.sin(angle) * force * 0.8) / n.proj.scale;
n.curr.z -= (force * 2.8) / n.proj.scale;
n.excitation = Math.max(n.excitation, ratio);
}
for (let s = 0; s < pointer.shockwaves.length; s++) {
const sw = pointer.shockwaves[s];
const swDx = n.proj.x - sw.x;
const swDy = n.proj.y - sw.y;
const swDist = Math.sqrt(swDx * swDx + swDy * swDy);
const ringDelta = Math.abs(swDist - sw.radius);
if (ringDelta < 45) {
const impulse = (1 - ringDelta / 45) * sw.strength * 28;
n.curr.z += impulse / n.proj.scale;
n.excitation = Math.max(n.excitation, 0.8);
}
}
}
}
const relaxationPasses = 3;
for (let p = 0; p < relaxationPasses; p++) {
for (let i = 0; i < links.length; i++) {
const link = links[i];
const na = nodes[link.p1];
const nb = nodes[link.p2];
const dx = nb.curr.x - na.curr.x;
const dy = nb.curr.y - na.curr.y;
const dz = nb.curr.z - na.curr.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
const diff = (dist - link.length) / (dist || 1);
if (!na.pinned) {
na.curr.x += dx * 0.5 * diff;
na.curr.y += dy * 0.5 * diff;
na.curr.z += dz * 0.5 * diff;
}
if (!nb.pinned) {
nb.curr.x -= dx * 0.5 * diff;
nb.curr.y -= dy * 0.5 * diff;
nb.curr.z -= dz * 0.5 * diff;
}
}
}
for (let i = 0; i < links.length; i++) {
const link = links[i];
const na = nodes[link.p1];
const nb = nodes[link.p2];
const avgScale = (na.proj.scale + nb.proj.scale) / 2;
const avgAlpha = (na.proj.alpha + nb.proj.alpha) / 2;
const isExcited = na.excitation > 0.1 || nb.excitation > 0.1;
if (isExcited) {
const glow = Math.max(na.excitation, nb.excitation);
ctx.strokeStyle = isDark
? `rgba(255, 255, 255, ${Math.min(1, 0.3 + glow * 0.7)})`
: `rgba(0, 0, 0, ${Math.min(1, 0.3 + glow * 0.7)})`;
ctx.lineWidth = (0.8 + glow * 1.2) * avgScale;
} else {
ctx.strokeStyle = `rgba(${strokeColor}, ${0.12 * avgAlpha})`;
ctx.lineWidth = 0.75 * avgScale;
}
ctx.beginPath();
ctx.moveTo(na.proj.x, na.proj.y);
ctx.lineTo(nb.proj.x, nb.proj.y);
ctx.stroke();
}
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (n.excitation > 0.25) {
const r = Math.min(2.6, 1.2 + n.excitation * 2) * n.proj.scale;
ctx.fillStyle = isDark ? "#ffffff" : "#000000";
ctx.beginPath();
ctx.arc(n.proj.x, n.proj.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
animId = requestAnimationFrame(loop);
};
animId = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animId);
}, [isRunning]);
const handlePointerMove = (e: React.MouseEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
pointerRef.current.x = x;
pointerRef.current.y = y;
const normX = (x / rect.width - 0.5) * 2;
const normY = (y / rect.height - 0.5) * 2;
pointerRef.current.targetAngleY = normX * 0.38;
pointerRef.current.targetAngleX = -normY * 0.28 + 0.15;
};
const handlePointerDown = (e: React.MouseEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container) return;
pointerRef.current.isDown = true;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
pointerRef.current.shockwaves.push({
x,
y,
radius: 10,
maxRadius: 360,
strength: 1.0,
});
};
const handlePointerUp = () => {
pointerRef.current.isDown = false;
};
const handlePointerLeave = () => {
pointerRef.current.x = -2000;
pointerRef.current.y = -2000;
pointerRef.current.isDown = false;
pointerRef.current.targetAngleX = 0.15;
pointerRef.current.targetAngleY = 0;
};
const triggerImpulse = () => {
const { width, height } = dimensionsRef.current;
pointerRef.current.shockwaves.push({
x: width / 2,
y: height / 2,
radius: 10,
maxRadius: Math.max(width, height) * 0.8,
strength: 1.2,
});
};
return (
<div
ref={containerRef}
onMouseMove={handlePointerMove}
onMouseDown={handlePointerDown}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerLeave}
className={cn(
"group relative flex h-full w-full select-none flex-col justify-between overflow-hidden rounded-2xl border border-neutral-200 bg-neutral-50 shadow-sm transition-colors duration-700 dark:border-neutral-800 dark:bg-[#09090b]",
className
)}
>
{/* 3D Canvas Viewport - Now fills 100% of container dimensions cleanly */}
<canvas
ref={canvasRef}
className="absolute inset-0 block h-full w-full cursor-crosshair"
/>
{/* Inner Content Wrapper handling padding safely without breaking canvas */}
<div className="relative z-20 flex h-full w-full flex-col justify-between p-5 md:p-8">
{/* Top Header Deck */}
<header className="flex w-full items-center justify-between font-mono text-[11px] text-neutral-600 dark:text-neutral-400">
<div className="flex items-center gap-3">
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-neutral-900 opacity-60 dark:bg-white" />
<span className="relative inline-flex size-2 rounded-full bg-neutral-900 dark:bg-white" />
</span>
<span className="font-semibold tracking-wider text-neutral-900 uppercase dark:text-neutral-100">
{tagline}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={triggerImpulse}
className="flex items-center gap-1 rounded-lg border border-neutral-300/80 bg-white/70 px-2.5 py-1.5 backdrop-blur-md transition-all hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900/70 dark:hover:bg-neutral-800"
title="Trigger Shockwave"
>
<Sparkles className="size-3 text-neutral-800 dark:text-neutral-200" />
<span className="hidden sm:inline font-mono text-[10px]">PULSE</span>
</button>
<button
onClick={() => setIsRunning((prev) => !prev)}
className="flex items-center gap-1.5 rounded-lg border border-neutral-300/80 bg-white/70 px-2.5 py-1.5 backdrop-blur-md transition-all hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900/70 dark:hover:bg-neutral-800"
>
{isRunning ? <Pause className="size-3" /> : <Play className="size-3" />}
<span className="font-mono text-[10px]">{isRunning ? "FREEZE" : "RUN"}</span>
</button>
</div>
</header>
{/* Center Minimal Typography */}
<main className="pointer-events-none flex flex-col items-center justify-center text-center">
<h1 className="font-mono text-5xl font-black tracking-tighter uppercase sm:text-7xl md:text-9xl text-neutral-900 dark:text-white">
{headline}
</h1>
</main>
{/* Empty bottom spacer to keep flex layout balanced */}
<div />
</div>
</div>
);
}
export default KineticFabric;