Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Netz

Partikel in der Akzentfarbe, verbunden, sobald sie sich nähern. Größere ziehen beim Scrollen weiter mit.

Staub

Feine Körner in der Textfarbe ohne Linien, wie Staub in einem Lichtstrahl.

Sternbild

Weniger, größere Punkte mit langen Linien, die auch zum Zeiger greifen.

Details

Was Partikel mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle ParticlesProps aus components/ui/particles.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • densityoptional
    number

    Particles per 10,000 px², default 1.1 (about 80 on a 1200 x 600 stage)

  • linkDistanceoptional
    number

    Draw lines between particles closer than this many px, 0 for none. Default 120

  • speedoptional
    number

    1 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]

  • interactiveoptional
    boolean

    Lines also reach out to the pointer (fine pointers only)

  • clearCenteroptional
    boolean

    Dims the field behind the middle so a headline stays calm, default true

  • seedoptional
    number

    Same seed, same field

  • parallaxoptional
    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).

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
components/ui/particles.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert