Particles
A canvas of slowly drifting particles with lines between near neighbours. Seeded, sharp on every screen and paused when nobody looks.
One of 6 components in Beams, particles and motion
Variants
Details
What Particles brings along, read from the registry and the source.
- Category
- Beams, particles and motion
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/particles.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ParticlesProps interface from components/ui/particles.tsx, with the comments from the code.
densityoptionalnumberParticles per 10,000 px², default 1.1 (about 80 on a 1200 x 600 stage)
linkDistanceoptionalnumberDraw lines between particles closer than this many px, 0 for none. Default 120
speedoptionalnumber1 is a gentle drift, 2 twice as fast
toneoptional'accent' | 'neutral'accent for the accent color, neutral for the text color
sizesoptional[number, number]Particle radius range in px, default [1, 2.2]
interactiveoptionalbooleanLines also reach out to the pointer (fine pointers only)
clearCenteroptionalbooleanDims the field behind the middle so a headline stays calm, default true
seedoptionalnumberSame seed, same field
parallaxoptionalnumberPixels the largest points drift against the page over a full scroll pass, smaller ones less, so the field gains depth. Default 0 (off).
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 234 lines
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';
export interface ParticlesProps {
/** Particles per 10,000 px², default 1.1 (about 80 on a 1200 x 600 stage) */
density?: number;
/** Draw lines between particles closer than this many px, 0 for none. Default 120 */
linkDistance?: number;
/** 1 is a gentle drift, 2 twice as fast */
speed?: number;
/** accent for the accent color, neutral for the text color */
tone?: 'accent' | 'neutral';
/** Particle radius range in px, default [1, 2.2] */
sizes?: [number, number];
/** Lines also reach out to the pointer (fine pointers only) */
interactive?: boolean;
/** Dims the field behind the middle so a headline stays calm, default true */
clearCenter?: boolean;
/** Same seed, same field */
seed?: number;
/** Pixels the largest points drift against the page over a full scroll pass, smaller ones less, so the field gains depth. Default 0 (off). */
parallax?: number;
className?: string;
}
function seeded(seed: number) {
let a = seed >>> 0 || 1;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface P { x: number; y: number; vx: number; vy: number; r: number; a: number }
/**
* A canvas of particles that drift slowly, with lines between near
* neighbours. Seeded, so the field always starts the same. Sized to its
* parent by ResizeObserver and sharp on retina screens. Stops when out of
* view, in hidden tabs and with reduced motion (then it draws one still
* frame). Fills its parent and never blocks clicks.
*/
export function Particles({ density = 1.1, linkDistance = 120, speed = 1, tone = 'accent', sizes = [1, 2.2], interactive = false, clearCenter = true, seed = 3, parallax = 0, className }: ParticlesProps) {
const [minR, maxR] = sizes;
const wrap = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const host = wrap.current;
const cv = canvas.current;
const ctx = cv?.getContext('2d');
if (!host || !cv || !ctx) return;
const reduced = prefersReducedMotion();
let color = '';
let line = '';
const readColor = () => {
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;visibility:hidden';
probe.style.color = tone === 'accent' ? 'color-mix(in oklab, var(--primary) 85%, var(--foreground))' : 'var(--foreground)';
host.appendChild(probe);
color = getComputedStyle(probe).color;
probe.style.color = tone === 'accent' ? 'color-mix(in oklab, var(--primary) 60%, var(--foreground))' : 'var(--foreground)';
line = getComputedStyle(probe).color;
probe.remove();
};
readColor();
let w = 0, h = 0, dpr = 1;
let parts: P[] = [];
let frame = 0;
let visible = true;
let last = 0;
let pointer: { x: number; y: number } | null = null;
// Scroll pass of the stage, 0.5 is the resting middle
let pass = 0.5;
const depth = parallax && !reduced ? parallax / Math.max(0.1, maxR) : 0;
const Y = (p: P) => p.y + (pass - 0.5) * depth * p.r;
// Positions are kept as fractions of the stage, so a resize keeps the picture
const build = () => {
const rnd = seeded(seed);
const n = Math.max(8, Math.round(((w * h) / 10000) * density));
parts = Array.from({ length: n }, () => {
const ang = rnd() * Math.PI * 2;
const v = 0.006 + rnd() * 0.012;
return { x: rnd() * w, y: rnd() * h, vx: Math.cos(ang) * v, vy: Math.sin(ang) * v, r: minR + rnd() * (maxR - minR), a: 0.35 + rnd() * 0.65 };
});
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const dark = !!host.closest('.dark');
const linkAlpha = dark ? 0.32 : 0.22;
if (linkDistance > 0) {
ctx.strokeStyle = line;
ctx.lineWidth = 1;
const d2max = linkDistance * linkDistance;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
for (let j = i + 1; j < parts.length; j++) {
const q = parts[j];
const dx = p.x - q.x, dy = Y(p) - Y(q);
const d2 = dx * dx + dy * dy;
if (d2 > d2max) continue;
ctx.globalAlpha = (1 - Math.sqrt(d2) / linkDistance) * linkAlpha;
ctx.beginPath();
ctx.moveTo(p.x, Y(p));
ctx.lineTo(q.x, Y(q));
ctx.stroke();
}
if (pointer) {
const dx = p.x - pointer.x, dy = Y(p) - pointer.y;
const d = Math.hypot(dx, dy);
if (d < linkDistance * 1.4) {
ctx.globalAlpha = (1 - d / (linkDistance * 1.4)) * linkAlpha * 1.8;
ctx.beginPath();
ctx.moveTo(p.x, Y(p));
ctx.lineTo(pointer.x, pointer.y);
ctx.stroke();
}
}
}
}
ctx.fillStyle = color;
for (const p of parts) {
ctx.globalAlpha = p.a * (dark ? 0.9 : 0.7);
ctx.beginPath();
ctx.arc(p.x, Y(p), p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const step = (time: number) => {
const dt = Math.min(48, last ? time - last : 16);
last = time;
const k = dt * speed;
for (const p of parts) {
p.x += p.vx * k;
p.y += p.vy * k;
// wrap around softly at the edges
if (p.x < -10) p.x = w + 10;
else if (p.x > w + 10) p.x = -10;
if (p.y < -10) p.y = h + 10;
else if (p.y > h + 10) p.y = -10;
}
draw();
frame = requestAnimationFrame(step);
};
const start = () => {
if (reduced) { draw(); return; }
if (!frame && visible && !document.hidden) { last = 0; frame = requestAnimationFrame(step); }
};
const stop = () => { cancelAnimationFrame(frame); frame = 0; };
const resize = () => {
const r = host.getBoundingClientRect();
const nw = Math.max(1, Math.round(r.width));
const nh = Math.max(1, Math.round(r.height));
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(nw * dpr);
cv.height = Math.round(nh * dpr);
if (!parts.length) { w = nw; h = nh; build(); }
else {
for (const p of parts) { p.x = (p.x / w) * nw; p.y = (p.y / h) * nh; }
const want = Math.max(8, Math.round(((nw * nh) / 10000) * density));
w = nw;
h = nh;
if (Math.abs(want - parts.length) > parts.length * 0.25) build();
}
draw();
};
const ro = new ResizeObserver(resize);
ro.observe(host);
resize();
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); else stop(); });
io.observe(host);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener('visibilitychange', onVis);
const mo = new MutationObserver(() => { readColor(); draw(); });
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-accent', 'style'] });
const onScroll = () => {
const r = host.getBoundingClientRect();
pass = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
};
if (depth) {
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
const parent = host.parentElement;
const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const move = (e: PointerEvent) => {
const r = host.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
};
const leave = () => { pointer = null; };
if (interactive && fine && parent && !reduced) {
parent.addEventListener('pointermove', move);
parent.addEventListener('pointerleave', leave);
}
start();
return () => {
stop();
ro.disconnect();
io.disconnect();
mo.disconnect();
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('scroll', onScroll);
parent?.removeEventListener('pointermove', move);
parent?.removeEventListener('pointerleave', leave);
};
}, [density, linkDistance, speed, tone, minR, maxR, interactive, seed, parallax]);
const edge = 'radial-gradient(ellipse 85% 80% at 50% 50%, #000 50%, transparent)';
const mask = clearCenter ? `${edge}, radial-gradient(ellipse var(--kitfx-clear, 34% 32%) at 50% 50%, rgb(0 0 0 / 0.3) 30%, #000)` : edge;
return (
<div
ref={wrap}
aria-hidden
className={cn('pointer-events-none absolute inset-0 overflow-hidden max-sm:[--kitfx-clear:62%_40%]', className)}
style={{ maskImage: mask, WebkitMaskImage: mask, maskComposite: 'intersect', WebkitMaskComposite: 'source-in' }}
>
<canvas ref={canvas} className="absolute inset-0 size-full" />
</div>
);
}Frequent questions
How do I install Particles?
With aiw add @ai-website/particles. The command puts components/ui/particles.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Particles have?
3 variants, Network, Dust and Constellation.
Can an AI assistant build with Particles?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Particles with aiw_components and install it with aiw_add @ai-website/particles.