Skip to content
UI Kit
Accent color

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

3 variants

Variants

Network

Particles in the accent color, linked when they come close. Larger ones drift further with the scroll.

Dust

Fine grains in the text color without lines, like dust in a beam of light.

Constellation

Fewer, larger points with long links that also reach for the pointer.

Details

What Particles brings along, read from the registry and the source.

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

Props

The ParticlesProps interface from components/ui/particles.tsx, with the comments from the code.

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

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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard