Particle Fields
Ambient canvas particle backdrops — a drifting pointer-parallax field, twinkling sparkles, and a 3D hyperspace 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
<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
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:
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.
-
resources/views/components/ui/particles.blade.php -
resources/js/ui/particles.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
@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>
/**
* 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