Skip to content

Components / Backgrounds

Particles

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.

Live demo

Drift.

move your cursor

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

1.6 kB raw

960 B gzip.

Uses the shared runtime (1.9 kB raw, once per site) and the shared canvas helper (2.1 kB raw, once per site). With those included: 4.9 kB raw.

CSS 5.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Particles from '@moonarc/core/Particles';
---
<section class="relative min-h-[28rem]">
  <Particles reach={140} links={90} color="var(--action)" />
  <h1 class="relative">Content above the field</h1>
</section>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, the canvas helper and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/particles.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and canvas.ts.

Particles.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>

Props

PropTypeDefaultDescription
countnumberfrom the areaDots; the default is one per 5 000 px², at most 160.
colorstringink at 55 %Dot (and link) colour.
speednumber18Drift in px per second.
reachnumber120Radius around the cursor that pushes dots away, in px; 0 turns it off. Never on coarse pointers.
sizenumber1.8Largest dot radius in px.
linksnumber0Draw lines between dots closer than this many px; 0 for none.

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.

Why it is built this way

Replaces: Particles (Magic UI) · Particles (React Bits) · SparklesCore (Aceternity) · tsparticles. See the migration table.

Dot Pattern

Backgrounds

A field of dots behind content, faded toward the edges, with an optional slow pulse.

every browseralways

Globe

Backgrounds

A turning globe of dots that you can drag.

every browseralwayspointer

Meteors

Backgrounds

Streaks falling diagonally across a twinkling sky.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown