Partikel
Eine Fläche mit langsam treibenden Partikeln und Linien zwischen nahen Nachbarn. Fest gesät, scharf auf jedem Bildschirm und angehalten, wenn niemand hinsieht.
Eine von 6 Components in Beams, Partikel und Bewegung
Variants
Details
Was Partikel mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Beams, Partikel und Bewegung
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/particles.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ParticlesProps aus components/ui/particles.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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).
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 234 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Partikel?
Mit aiw add @ai-website/particles. Der Befehl legt die Datei components/ui/particles.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Partikel?
3 Varianten, Netz, Staub und Sternbild.
Kann ein KI-Assistent Partikel einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Partikel mit aiw_components und installieren sie mit aiw_add @ai-website/particles.