Kinetic Particle Fabric
Kinetic Particle Fabric é um componente de efeito para React e Tailwind CSS, escrito para a NedDev e teu para usares. Copia e cola no teu projeto.
NedDev
effects
O que é
Kinetic Particle Fabric é um componente de efeito para React e Tailwind CSS, escrito para a NedDev e teu para usares. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Quatro cópias grátis por semana. Sem cartão.
Precisa de
npm i lucide-react
Licença
Este componente foi escrito para este catálogo. É teu para usares em trabalho de cliente e em produtos que vendas, nos termos de uso.
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;