Skip to content
Brok UI

Loading…

No results

Particle Fields

Open source

Ambient canvas particle backdrops — a drifting pointer-parallax field, twinkling sparkles, and a 3D hyperspace starfield.

Version
v1.1.2
Stability
stable
License
MIT
Related
Sparkles
Starfield

Particles — An ambient field of drifting particles on a canvas that gently parallax toward the pointer, in a semantic token color. DPR aware and reduced-motion safe (static frame).

Preview

previews.components.particles.default.blade.php Blade
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-foreground">
    <x-ui.particles :quantity="80" />
</div>

Installation

terminal
php artisan ui:add particles

Note

This component ships an Alpine behavior module at resources/js/ui/particles.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './particles.js';

Registry contract

php artisan ui:add particles writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/particles.blade.php
  • js resources/js/ui/particles.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

particles.md
# Brok UI: Particles (`particles`)

An ambient field of drifting particles on a canvas that gently parallax toward the pointer, in a semantic token color. DPR aware and reduced-motion safe (static frame).

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add particles
```

## Usage

```blade
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-foreground">
    <x-ui.particles :quantity="80" />
</div>
```

## Props

- `quantity` (int, default `60`) — Number of particles, clamped to 400.
- `color` (string, default `foreground`) — Semantic colour token for the particles (foreground, muted, primary, secondary, accent).
- `size` (float, default `1.2`) — Particle radius in pixels.
- `speed` (int, default `1`) — Scales the base per-frame drift of every particle.
- `staticity` (int, default `50`) — Higher values reduce how strongly particles are pulled toward the pointer.
- `ease` (int, default `50`) — Higher values slow how quickly particles catch up to the pointer pull.
- `mouse` (bool, default `true`) — Enables pointer parallax when true; false leaves the particles drifting on their own.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding an ambient, interactive dot field that reacts to the pointer behind a hero or section.
- Wanting a background effect themed through a text-* colour utility instead of a hard-coded colour.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting glints that twinkle in place rather than drift with the pointer; use sparkles instead.
- Wanting a warp/hyperspace motion instead of a gentle drift; use starfield instead.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:particles>` tag (or `<x-ui.particles>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/particles
- Registry JSON (files, props, contract): https://brokui.dev/r/open/particles.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
quantity int 60 Number of particles, clamped to 400.
color string foreground Semantic colour token for the particles (foreground, muted, primary, secondary, accent).
size float 1.2 Particle radius in pixels.
speed int 1 Scales the base per-frame drift of every particle.
staticity int 50 Higher values reduce how strongly particles are pulled toward the pointer.
ease int 50 Higher values slow how quickly particles catch up to the pointer pull.
mouse bool true Enables pointer parallax when true; false leaves the particles drifting on their own.

Slots

  • default — Not used by this component; present for attribute-bag compatibility.

Data slots

Stable hooks for CSS overrides and browser tests.

particles

Behavior

  • Seeds quantity particles on a canvas that drift continuously and, when mouse is enabled, gently parallax toward the pointer position with strength controlled by staticity and ease.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Adding an ambient, interactive dot field that reacts to the pointer behind a hero or section.
  • Wanting a background effect themed through a text-* colour utility instead of a hard-coded colour.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Wanting glints that twinkle in place rather than drift with the pointer; use sparkles instead.
  • Wanting a warp/hyperspace motion instead of a gentle drift; use starfield instead.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root
Theming hooks
particles

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The host is aria-hidden, since it carries no content, only an ambient effect.
  • Reduced motion paints a single static frame and never starts the animation loop or pointer tracking.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-foreground">
        <x-ui.particles :quantity="80" />
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/particles.blade.php Blade
@props([
    'quantity' => 60,
    'color' => 'foreground',
    'size' => 1.2,
    'speed' => 1,
    'staticity' => 50,
    'ease' => 50,
    'mouse' => true,
])

@php
    // An ambient field of drifting particles that parallax toward the pointer.
    // The dot colour is read at runtime from the canvas's computed `color` (a
    // text-* token, default text-foreground), so theming/utilities change the
    // particles — nothing is hard-coded. DPR-aware and reduced-motion safe
    // (a single static frame is painted, no animation loop runs).
    $colorClasses = [
        'foreground' => 'text-foreground',
        'muted' => 'text-muted-foreground',
        'primary' => 'text-primary',
        'secondary' => 'text-secondary-foreground',
        'accent' => 'text-accent-foreground',
    ];
    $colorClass = $colorClasses[$color] ?? 'text-foreground';

    $config = [
        'quantity' => max(1, min(400, (int) $quantity)),
        'size' => max(0.2, (float) $size),
        // speed scales the base per-frame drift of every particle.
        'speed' => max(0, (float) $speed),
        // higher staticity = less pointer pull; higher ease = slower catch-up.
        'staticity' => max(1, (float) $staticity),
        'ease' => max(1, (float) $ease),
        'mouse' => (bool) $mouse,
    ];
@endphp

{{-- Decorative fill layer. The whole component is aria-hidden: it carries no
     content, just an ambient effect, so assistive tech skips it. The canvas
     inherits its dot colour from this element's computed `color`. --}}
<div
    data-slot="particles"
    x-data="uiParticles(@js($config))"
    x-ref="root"
    aria-hidden="true"
    {{ $attributes->merge(['class' => $colorClass.' pointer-events-none absolute inset-0 block h-full w-full overflow-hidden']) }}
>
    <canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
resources/js/ui/particles.js JS
/**
 * Particles behavior (ambient drifting particle field with pointer parallax).
 *
 * A single <canvas> fills the parent box. `quantity` circles are seeded at
 * random positions, each with a tiny constant drift (dx/dy scaled by `speed`)
 * and a per-particle `magnetism`. Every frame we:
 *   1. clear the canvas;
 *   2. advance each particle by its drift, and ease a per-particle translate
 *      toward the pointer offset — `staticity` damps the pull, `ease` slows the
 *      catch-up, so the whole field gently parallaxes around the cursor;
 *   3. fade each particle's alpha up as it enters the box and down as it nears
 *      an edge; once it leaves, it is recycled to a fresh random position.
 *
 * Colour is SEMANTIC: the dot colour is read from the canvas's computed `color`
 * (a text-* token, default text-foreground). Nothing is hard-coded — theming or
 * a text-* utility on the host changes the particles.
 *
 * Sizing: the backing store is scaled for devicePixelRatio and a ResizeObserver
 * re-seeds the field when the box resizes. Reduced motion: a single STATIC frame
 * is drawn and no animation loop / rAF / pointer listener runs.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiParticles', (config = {}) => ({
        quantity: Math.max(1, config.quantity ?? 60),
        size: Math.max(0.2, config.size ?? 1.2),
        speed: Math.max(0, config.speed ?? 1),
        staticity: Math.max(1, config.staticity ?? 50),
        ease: Math.max(1, config.ease ?? 50),
        mouse: config.mouse ?? true,

        _ctx: null,
        _dpr: 1,
        _cssW: 0,
        _cssH: 0,
        _particles: [],
        // Resolved from the host's computed `color` token in layout() before any
        // draw; the placeholder is never painted. `_rgb` caches the PARSED channels
        // so the hot draw loop never re-parses the colour string per particle per
        // frame — it's re-read only on layout (resize) and on a `.dark` theme flip.
        _color: 'transparent',
        _rgb: { r: 0, g: 0, b: 0 },
        _mouse: { x: 0, y: 0 },
        _raf: null,
        _ro: null,
        _mo: null,
        _onPointerMove: null,
        _reduced: false,
        _viewObserver: null,
        _offscreen: false,

        init() {
            const canvas = this.$refs.canvas;
            this._ctx = canvas.getContext('2d');
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this.layout();

            // Re-read the token colour when the app flips the `.dark` class on
            // <html>; cheap (one parse) and keeps a live theme toggle on-token.
            // Active in both motion modes so a static field also re-themes.
            this._mo = new MutationObserver(() => {
                this.resolveColor();
                if (this._reduced) this.drawStatic();
            });
            this._mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });

            if (this._reduced) {
                // Static frame only: particles painted at their seed positions.
                this.drawStatic();
                return;
            }

            if (this.mouse) {
                this._onPointerMove = (e) => this.trackPointer(e);
                window.addEventListener('pointermove', this._onPointerMove, { passive: true });
            }

            let pending = false;
            this._ro = new ResizeObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => {
                    pending = false;
                    this.layout();
                });
            });
            this._ro.observe(this.$refs.root);

            this._raf = requestAnimationFrame(() => this.loop());

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame(() => this.loop());
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        // Resolve the dot colour from the host's computed `color` token and cache
        // the parsed RGB channels for the draw loop.
        resolveColor() {
            const c = getComputedStyle(this.$refs.root).color;
            this._color = c && c !== '' ? c : getComputedStyle(document.body).color;
            this._rgb = this.parseRgb(this._color);
        },

        parseRgb(str) {
            const m = String(str).match(/rgba?\(([^)]+)\)/);
            if (!m) return { r: 0, g: 0, b: 0 };
            const parts = m[1].split(',').map((p) => parseFloat(p.trim()));
            return { r: parts[0] || 0, g: parts[1] || 0, b: parts[2] || 0 };
        },

        // Pointer position relative to the box centre — what particles ease toward.
        trackPointer(e) {
            const rect = this.$refs.root.getBoundingClientRect();
            const x = e.clientX - rect.left - this._cssW / 2;
            const y = e.clientY - rect.top - this._cssH / 2;
            const inside = x < this._cssW / 2 && x > -this._cssW / 2 && y < this._cssH / 2 && y > -this._cssH / 2;
            if (inside) {
                this._mouse.x = x;
                this._mouse.y = y;
            }
        },

        newParticle() {
            return {
                x: Math.floor(Math.random() * this._cssW),
                y: Math.floor(Math.random() * this._cssH),
                translateX: 0,
                translateY: 0,
                size: Math.max(0.2, Math.random() * 2 + this.size),
                alpha: 0,
                targetAlpha: parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)),
                dx: (Math.random() - 0.5) * 0.1 * this.speed,
                dy: (Math.random() - 0.5) * 0.1 * this.speed,
                magnetism: 0.1 + Math.random() * 4,
            };
        },

        seed() {
            this._particles = [];
            for (let i = 0; i < this.quantity; i++) {
                this._particles.push(this.newParticle());
            }
        },

        // Size the backing store for DPR and (re)seed the field.
        layout() {
            const canvas = this.$refs.canvas;
            const root = this.$refs.root;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            this.resolveColor();

            this._cssW = Math.max(1, root.clientWidth || 1);
            this._cssH = Math.max(1, root.clientHeight || 1);

            canvas.width = Math.round(this._cssW * this._dpr);
            canvas.height = Math.round(this._cssH * this._dpr);
            this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);

            this.seed();

            if (this._reduced) this.drawStatic();
        },

        drawParticle(p) {
            const ctx = this._ctx;
            const { r, g, b } = this._rgb;
            ctx.beginPath();
            ctx.arc(p.x + p.translateX, p.y + p.translateY, p.size, 0, Math.PI * 2);
            ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${p.alpha})`;
            ctx.fill();
        },

        // Distance from each edge → a 0..1 fade so particles dissolve near edges.
        edgeAlpha(p) {
            const edges = [p.x + p.translateX, this._cssW - p.x - p.translateX, p.y + p.translateY, this._cssH - p.y - p.translateY];
            const closest = Math.min(...edges);
            const fadeStart = 20;
            if (closest > fadeStart) return 1;
            return Math.max(0, parseFloat((closest / fadeStart).toFixed(2)));
        },

        loop() {
            const ctx = this._ctx;
            ctx.clearRect(0, 0, this._cssW, this._cssH);

            for (let i = 0; i < this._particles.length; i++) {
                const p = this._particles[i];

                const remap = this.edgeAlpha(p);
                if (remap > 1) {
                    p.alpha += 0.02;
                    if (p.alpha > p.targetAlpha) p.alpha = p.targetAlpha;
                } else {
                    p.alpha = p.targetAlpha * remap;
                }

                p.x += p.dx;
                p.y += p.dy;
                p.translateX += (this._mouse.x / (this.staticity / p.magnetism) - p.translateX) / this.ease;
                p.translateY += (this._mouse.y / (this.staticity / p.magnetism) - p.translateY) / this.ease;

                this.drawParticle(p);

                // Recycle once a particle drifts fully out of the box.
                if (
                    p.x < -p.size ||
                    p.x > this._cssW + p.size ||
                    p.y < -p.size ||
                    p.y > this._cssH + p.size
                ) {
                    this._particles[i] = this.newParticle();
                }
            }

            this._raf = requestAnimationFrame(() => this.loop());
        },

        // Reduced motion: a single static field at full target alpha, no rAF.
        drawStatic() {
            const ctx = this._ctx;
            if (!ctx) return;
            ctx.clearRect(0, 0, this._cssW, this._cssH);
            for (const p of this._particles) {
                p.alpha = p.targetAlpha;
                this.drawParticle(p);
            }
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
            if (this._ro) {
                this._ro.disconnect();
                this._ro = null;
            }
            if (this._mo) {
                this._mo.disconnect();
                this._mo = null;
            }
            if (this._onPointerMove) {
                window.removeEventListener('pointermove', this._onPointerMove);
                this._onPointerMove = null;
            }
            this._particles = [];
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0