# Particles (Moonarc)

A field of drifting dots that part around the cursor, on a canvas that draws only while on screen in a visible tab. The count follows the area (capped), every constant is a custom property declared per preset, the colours are the theme's and are re-read when it changes. Without JavaScript a still dot pattern in CSS stands in; under reduced motion one frame is drawn.

- Import: `import Particles from '@moonarc/core/Particles'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/particles.json`
- Tier C · category background · trigger always, pointer
- Readout: `<Particles reach={120} links={0}>`
- Browser support: widely (every browser)
- Measured cost: 1.6 kB raw JS · 960 B gzip · + runtime + canvas (with dependencies 4.9 kB raw; CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/particles/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `count` | `number` | `from the area` | Dots; the default is one per 5 000 px², at most 160. |
| `color` | `string` | `ink at 55 %` | Dot (and link) colour. |
| `speed` | `number` | `18` | Drift in px per second. |
| `reach` | `number` | `120` | Radius around the cursor that pushes dots away, in px; 0 turns it off. Never on coarse pointers. |
| `size` | `number` | `1.8` | Largest dot radius in px. |
| `links` | `number` | `0` | Draw lines between dots closer than this many px; 0 for none. |

## Usage

```astro
<section class="relative min-h-[28rem]">
  <Particles reach={140} links={90} color="var(--action)" />
  <h1 class="relative">Content above the field</h1>
</section>
```

## Reduced motion

One frame is drawn and the loop never starts; the cursor does not push.

## With ClientRouter

Bound through the shared runtime: the frame loop, both observers and the listeners are released before the swap; five round trips leave one loop per instance.

## Craft

- The loop runs only while the host intersects the viewport and the tab is visible: content-visibility on a card does not stop a requestAnimationFrame, so the component stops it.
- Device pixel ratio is capped at 2 and the size is read once from a ResizeObserver; nothing listens to the resize event.
- Push, friction, density and speed are custom properties this stylesheet declares (snap pushes harder and settles faster, ambient drifts), so the field has the page's character and the script carries no constant.
- The cursor writes an impulse, not a position: dots are pushed and glide back on friction, which reads as water rather than a hole following the mouse.
- Colours are read from the canvas element's computed style (its color and a border colour the stylesheet maps to the tokens), so oklch, color-mix and the theme all arrive as rgb the context can paint; a MutationObserver on data-theme re-reads them.
- Without a script the host paints the same dots as a tiled gradient; the canvas fades it out after its first frame.

## Replaces

- Particles (Magic UI)
- Particles (React Bits)
- SparklesCore (Aceternity)
- tsparticles

## Source

```astro
---
/**
 * Particles — a field of drifting dots that part around the cursor, on a
 * canvas that only draws while it is on screen and the tab is visible. The
 * count follows the box's area (capped, so a phone never lifts two hundred
 * points), every constant — speed, reach, push, friction, link distance —
 * is a custom property this stylesheet declares, and the colours are the
 * theme's, re-read when it changes. Without JavaScript the host shows a
 * still dot pattern in CSS; under reduced motion the canvas draws one
 * frame and stops. The lifecycle (DPR cap, ResizeObserver, visibility
 * gate, theme observer) is the shared canvas helper. Fills its positioned
 * parent.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Dots; default from the area, at most 160. */
  count?: number;
  /** Dot colour. */
  color?: string;
  /** Drift in px per second. */
  speed?: number;
  /** Radius around the cursor that pushes dots away, px; 0 turns it off. */
  reach?: number;
  /** Largest dot radius in px. */
  size?: number;
  /** Draw lines between dots closer than this, px; 0 for none. */
  links?: number;
}

const { count, color = 'color-mix(in srgb, var(--ma-ink) 55%, transparent)', speed = 18, reach = 120, size = 1.8, links = 0, class: className, style, ...rest } = Astro.props;
const inline = [count ? `--ma-particles-count:${count}` : '', `--ma-particles-color:${color}`, `--ma-particles-speed:${speed}`, `--ma-particles-reach:${reach}`, `--ma-particles-size:${size}`, `--ma-particles-links:${links}`, typeof style === 'string' ? style : '']
  .filter(Boolean)
  .join(';');
---

<div class:list={['ma-particles', className]} data-ma-particles aria-hidden="true" style={inline} {...rest}>
  <canvas></canvas>
</div>

<style is:global>
  @layer components {
    :where(.ma-particles) {
      position: absolute;
      inset: 0;
      overflow: hidden;
      pointer-events: none;
      contain: strict;
      /* physics: --ma-particles-speed px/s · reach px · push (impulse per px inside the reach) · friction (impulse kept per frame) */
      --ma-particles-push: 0.12;
      --ma-particles-friction: 0.9;
      --ma-particles-density: 5000;
    }
    :where([data-ma-preset='snap'] .ma-particles) {
      --ma-particles-push: 0.2;
      --ma-particles-friction: 0.85;
    }
    :where([data-ma-preset='ambient'] .ma-particles),
    :where([data-ma-preset='gentle'] .ma-particles) {
      --ma-particles-push: 0.08;
      --ma-particles-friction: 0.94;
    }
    /* without a script the same dots, still, in CSS; the canvas replaces them once it has drawn */
    :where(.ma-particles)::before {
      content: '';
      position: absolute;
      inset: 0;
      /* --ma-particles-size is a number (the script reads it); the gradient wants a length */
      background-image: radial-gradient(circle, var(--ma-particles-color) calc(var(--ma-particles-size, 1.8) * 1px), transparent calc(var(--ma-particles-size, 1.8) * 1px + 0.6px));
      background-size: 56px 56px;
      background-position: 11px 19px;
      mask-image: radial-gradient(ellipse at center, #000 40%, transparent 80%);
      transition: opacity var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-particles[data-live])::before {
      opacity: 0;
    }
    :where(.ma-particles canvas) {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      pointer-events: auto;
      /* the theme-resolved colours the script reads: dots, links */
      color: var(--ma-particles-color);
      border: 0 solid;
      border-top-color: var(--ma-particles-color);
    }
    /* the pointer is read on the host; the canvas takes events so the field answers the cursor while nothing above it is covered */
    @media (pointer: coarse) {
      :where(.ma-particles canvas) {
        pointer-events: none;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-particles)::before {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';
  import { colors, fit, loop, num, onTheme, visible } from '../lib/canvas';

  onMount<HTMLElement>('[data-ma-particles]', (el, { signal, reducedMotion }) => {
    const canvas = el.querySelector('canvas')!;
    let ctx: CanvasRenderingContext2D;
    let w = 0;
    let h = 0;
    // x, y, drift x, drift y, impulse x, impulse y, radius
    let ps: number[][] = [];
    let dot = '';
    let line = '';
    let px = -1e4;
    let py = -1e4;
    const v = (n: string, f: number) => num(el, '--ma-particles-' + n, f);
    const seed = () => {
      const n = Math.min(160, Math.round(v('count', (w * h) / v('density', 5000))));
      const s = v('speed', 18) / 1000;
      const r = v('size', 1.8);
      ps = Array.from({ length: n }, () => {
        const a = Math.random() * Math.PI * 2;
        const k = s * (0.4 + Math.random() * 0.8);
        return [Math.random() * w, Math.random() * h, Math.cos(a) * k, Math.sin(a) * k, 0, 0, r * (0.4 + Math.random() * 0.6)];
      });
    };
    const theme = () => ([dot, line] = colors(canvas));
    const frame = (_t: number, dt: number) => {
      const reach = v('reach', 120);
      const push = v('push', 0.12);
      const fr = v('friction', 0.9);
      const link = v('links', 0);
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = dot;
      ctx.strokeStyle = line;
      ctx.lineWidth = 0.5;
      for (const p of ps) {
        if (reach > 0) {
          const dx = p[0]! - px;
          const dy = p[1]! - py;
          const d = Math.hypot(dx, dy);
          if (d < reach && d > 0) {
            const f = ((reach - d) / reach) * push;
            p[4]! += (dx / d) * f;
            p[5]! += (dy / d) * f;
          }
        }
        p[4]! *= fr;
        p[5]! *= fr;
        p[0]! += p[2]! * dt + p[4]!;
        p[1]! += p[3]! * dt + p[5]!;
        // wrap around the edges
        if (p[0]! < -4) p[0] = w + 4;
        else if (p[0]! > w + 4) p[0] = -4;
        if (p[1]! < -4) p[1] = h + 4;
        else if (p[1]! > h + 4) p[1] = -4;
        ctx.beginPath();
        ctx.arc(p[0]!, p[1]!, p[6]!, 0, 7);
        ctx.fill();
      }
      if (link > 0) {
        ctx.beginPath();
        for (let i = 0; i < ps.length; i++)
          for (let j = i + 1; j < ps.length; j++) {
            const a = ps[i]!;
            const b = ps[j]!;
            const dx = a[0]! - b[0]!;
            const dy = a[1]! - b[1]!;
            if (dx * dx + dy * dy < link * link) {
              ctx.moveTo(a[0]!, a[1]!);
              ctx.lineTo(b[0]!, b[1]!);
            }
          }
        ctx.stroke();
      }
    };
    const run = loop(frame, signal);
    let on = false;
    // on screen and motion allowed → run; otherwise one still frame (reduced motion is read live: the page's switch stops a running field)
    const sync = () => (on && !prefersReducedMotion() ? run.wake() : (run.sleep(), run.once()));
    fit(canvas, el, signal, (W, H, c) => {
      w = W;
      h = H;
      ctx = c;
      seed();
      run.once();
    });
    theme();
    onTheme(signal, () => {
      theme();
      sync();
    });
    el.setAttribute('data-live', '');
    visible(el, signal, (o) => ((on = o), sync()));
    if (!reducedMotion && matchMedia('(pointer: fine)').matches) {
      canvas.addEventListener(
        'pointermove',
        (e) => {
          const r = canvas.getBoundingClientRect();
          px = e.clientX - r.left;
          py = e.clientY - r.top;
        },
        { signal, passive: true },
      );
      canvas.addEventListener('pointerleave', () => (px = py = -1e4), { signal });
    }
  });
</script>

```
