# Particle text

> Particles gather into letters and scatter on touch.

- Type: Component (pixel)
- Page: https://textlab.dev/components/particle-text
- Markdown: https://textlab.dev/components/particle-text.md
- Registry item: https://textlab.dev/r/particle-text.json
- Source file: `registry/textlab/particle-text/particle-text.tsx`
- Dependencies: none
- Variants: dust, sand, stars
- Keywords: particles, particle text, canvas, interactive, stardust, sand

## When to use

- Launch pages and immersive heroes.
- One large word per screen.

## When not to use

- Several instances; each one simulates thousands of particles.
- Low-power contexts where battery matters.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/particle-text
pnpm dlx shadcn@latest add @textlab/particle-text
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/particle-text.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { ParticleText } from "@/components/textlab/particle-text/particle-text";

export function Example() {
  return <ParticleText as="h1" text="Stardust" variant="stars" />;
}
```

## API reference

### ParticleText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"dust" \| "sand" \| "stars"` | `"dust"` | Fine quick dust, heavy warm sand that sags under the pointer, or twinkling stars. |
| `gap` | `number` | `3` | Sampling gap in px; smaller means more particles. |
| `radius` | `number` | `1.2` | Pointer push radius in em. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Decorative canvas; the text is read from a hidden copy.
- Particles settle into readable letters within a second or two.

## Motion

- Particles gather from a wide cloud when the word scrolls in.
- The pointer pushes them away; springs pull them home.

## Reduced motion

Plain text instead of the canvas.

## Performance

- Capped at 9,000 particles; the loop sleeps once they settle (except stars, which twinkle).
- Typed arrays, one draw call per frame for dust.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: dust, sand, stars.

## Related

- [Dither text](https://textlab.dev/components/dither-text.md): Ordered dither and halftone dots form the type.
- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.

## Also in pixel

- [ASCII text](https://textlab.dev/components/ascii-text.md): The title rendered in ASCII glyphs that ripple.
- [Pixel sort](https://textlab.dev/components/pixel-sort.md): Rows sort into place like a glitch artwork.
- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.

## Source

### components/textlab/particle-text/particle-text.tsx

```tsx
"use client";

import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { CanvasText } from "../lib/canvas-text";
import { hashString, seeded } from "../lib/math";
import { fitCanvas, rasterize } from "../lib/raster";
import { useFrame } from "../lib/use-frame";
import { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";

export type ParticleTextVariant = "dust" | "sand" | "stars";

export interface ParticleTextProps {
  text: string;
  /** dust is fine and quick, sand is heavier and warm, stars twinkle and glow. */
  variant?: ParticleTextVariant;
  /** Sampling gap in px between particles. Smaller means more particles. */
  gap?: number;
  /** Pointer push radius, in em. */
  radius?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

type Swarm = { hx: Float32Array; hy: Float32Array; x: Float32Array; y: Float32Array; vx: Float32Array; vy: Float32Array; tint: Float32Array; count: number; bleed: number; em: number; dpr: number };

/**
 * Particles gather into letters and scatter where you touch. Each particle is sprung to its home
 * in the glyph; the pointer pushes them aside and they flow back when it leaves.
 */
export function ParticleText({ text, variant = "dust", gap = 3, radius = 1.2, as, reducedMotion = "user", className, style }: ParticleTextProps) {
  const { ref: root, on } = useTrigger<HTMLElement>({ trigger: "inView" });
  const canvas = useRef<HTMLCanvasElement | null>(null);
  const swarm = useRef<Swarm | null>(null);
  const reduced = useReduced(reducedMotion);
  const pointer = usePointer(root, { enabled: !reduced });
  const settled = useRef(false);
  const time = useRef(0);

  useEffect(() => {
    const node = root.current;
    const c = canvas.current;
    if (!node || !c || reduced) return;
    const build = () => {
      const { width, height, dpr } = fitCanvas(c);
      const raster = rasterize(node, text, width, height, { dpr: 1 });
      const rand = seeded(hashString(text));
      const hx: number[] = [];
      const hy: number[] = [];
      const step = Math.max(1.5, gap);
      for (let y = 0; y < raster.height; y += step) {
        for (let x = 0; x < raster.width; x += step) {
          if (raster.data[(Math.round(y) * raster.width + Math.round(x)) * 4 + 3] > 128) {
            hx.push(x + (rand() - 0.5) * step * 0.6);
            hy.push(y + (rand() - 0.5) * step * 0.6);
          }
        }
      }
      const count = Math.min(hx.length, 9000);
      const s: Swarm = {
        hx: Float32Array.from(hx.slice(0, count)),
        hy: Float32Array.from(hy.slice(0, count)),
        x: new Float32Array(count),
        y: new Float32Array(count),
        vx: new Float32Array(count),
        vy: new Float32Array(count),
        tint: new Float32Array(count),
        count,
        bleed: (width - node.getBoundingClientRect().width) / 2,
        em: parseFloat(getComputedStyle(node).fontSize) || 16,
        dpr,
      };
      for (let i = 0; i < count; i++) {
        // Start scattered in a wide cloud; the intro gathers them home.
        const a = rand() * Math.PI * 2;
        const r = (0.4 + rand()) * width * 0.6;
        s.x[i] = width / 2 + Math.cos(a) * r;
        s.y[i] = height / 2 + Math.sin(a) * r * 0.5;
        s.tint[i] = rand();
      }
      swarm.current = s;
      settled.current = false;
    };
    build();
    const observer = new ResizeObserver(build);
    observer.observe(node);
    document.fonts?.ready.then(build);
    return () => observer.disconnect();
  }, [text, gap, reduced, root]);

  useFrame(
    (_, dt) => {
      const s = swarm.current;
      const c = canvas.current;
      const node = root.current;
      if (!s || !c || !node) return;
      time.current += dt;
      const p = pointer.current;
      if (settled.current && !p.active && variant !== "stars") return;
      const ctx = c.getContext("2d");
      if (!ctx) return;
      const px = p.x + s.bleed;
      const py = p.y + s.bleed;
      const reach = radius * s.em;
      const heavy = variant === "sand";
      const stiffness = on ? (heavy ? 40 : 70) : 0;
      const damping = heavy ? 7 : 9;
      let energy = 0;
      for (let i = 0; i < s.count; i++) {
        let ax = (s.hx[i] - s.x[i]) * stiffness - s.vx[i] * damping;
        let ay = (s.hy[i] - s.y[i]) * stiffness - s.vy[i] * damping;
        if (p.active) {
          const dx = s.x[i] - px;
          const dy = s.y[i] - py;
          const d2 = dx * dx + dy * dy;
          if (d2 < reach * reach) {
            const d = Math.sqrt(d2) || 1;
            const push = (1 - d / reach) * (heavy ? 5200 : 8000);
            ax += (dx / d) * push;
            ay += (dy / d) * push + (heavy ? 900 : 0);
          }
        }
        s.vx[i] += ax * dt;
        s.vy[i] += ay * dt;
        s.x[i] += s.vx[i] * dt;
        s.y[i] += s.vy[i] * dt;
        energy += Math.abs(s.vx[i]) + Math.abs(s.vy[i]);
      }
      settled.current = on && energy / s.count < 0.05;

      ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
      ctx.clearRect(0, 0, c.width, c.height);
      const color = getComputedStyle(node).color;
      ctx.fillStyle = color;
      const size = variant === "dust" ? 1.4 : variant === "sand" ? 1.8 : 1.6;
      if (variant === "stars") {
        for (let i = 0; i < s.count; i++) {
          ctx.globalAlpha = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(time.current * (1.5 + s.tint[i] * 3) + s.tint[i] * 40));
          ctx.fillRect(s.x[i] - size / 2, s.y[i] - size / 2, size, size);
        }
        ctx.globalAlpha = 1;
      } else if (heavy) {
        for (let i = 0; i < s.count; i++) {
          ctx.globalAlpha = 0.55 + s.tint[i] * 0.45;
          ctx.fillRect(s.x[i], s.y[i], size, size);
        }
        ctx.globalAlpha = 1;
      } else {
        ctx.beginPath();
        for (let i = 0; i < s.count; i++) ctx.rect(s.x[i], s.y[i], size, size);
        ctx.fill();
      }
    },
    { ref: root, enabled: !reduced },
  );

  return (
    <CanvasText
      text={text}
      as={as}
      className={className}
      style={style}
      rootRef={root}
      canvasRef={canvas}
      bleed={0.6}
      fallback={reduced ? text : undefined}
      canvasStyle={variant === "stars" ? { filter: "drop-shadow(0 0 2px currentColor)" } : undefined}
    />
  );
}

export default ParticleText;
```

Full library index: https://textlab.dev/llms.txt
