Particle Fields
Ambient canvas particle backdrops — a drifting pointer-parallax field, twinkling sparkles, and a 3D hyperspace starfield.
Preview
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-foreground">
<x-ui.sparkles :density="140" />
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-semibold tracking-tight text-foreground">Sparkles</span>
</div>
</div>
Installation
php artisan ui:add sparkles
Note
This component ships an Alpine behavior module at
resources/js/ui/sparkles.js. Import it once from your bundle so it registers on alpine:init:
import './sparkles.js';
Registry contract
php artisan ui:add sparkles
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/sparkles.blade.php -
resources/js/ui/sparkles.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: Sparkles (`sparkles`)
A twinkling sparkle field on a canvas: small glints fade their opacity in and out and slowly drift in place, 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 sparkles
```
## 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.sparkles :density="140" />
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-semibold tracking-tight text-foreground">Sparkles</span>
</div>
</div>
```
## Props
- `density` (int, default `80`) — How many glints to seed, scaled by box area and clamped to 600.
- `minSize` (float, default `0.6`) — Minimum glint size in pixels.
- `maxSize` (float, default `1.8`) — Maximum glint size in pixels.
- `speed` (int, default `1`) — Scales how fast each glint advances through its twinkle pulse.
- `color` (string, default `foreground`) — Semantic colour token for the glints (foreground, muted, primary, secondary, accent).
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a gentle, glittery ambient field that twinkles in place, without any pointer reaction.
- 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 parallax toward the pointer; use particles instead.
- Wanting a warp/hyperspace motion instead of twinkling in place; use starfield instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:sparkles>` tag (or `<x-ui.sparkles>`) 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/sparkles
- Registry JSON (files, props, contract): https://brokui.dev/r/open/sparkles.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 |
|---|---|---|---|
| density | int | 80 | How many glints to seed, scaled by box area and clamped to 600. |
| minSize | float | 0.6 | Minimum glint size in pixels. |
| maxSize | float | 1.8 | Maximum glint size in pixels. |
| speed | int | 1 | Scales how fast each glint advances through its twinkle pulse. |
| color | string | foreground | Semantic colour token for the glints (foreground, muted, primary, secondary, accent). |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Seeds density glints at random positions that pulse their opacity in and out with a tiny drift; unlike particles, sparkles never react to the pointer.
- 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 a gentle, glittery ambient field that twinkles in place, without any pointer reaction.
- 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 parallax toward the pointer; use particles instead.
- Wanting a warp/hyperspace motion instead of twinkling in place; 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.
- 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.sparkles :density="140" />
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-semibold tracking-tight text-foreground">Sparkles</span>
</div>
</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([
'density' => 80,
'minSize' => 0.6,
'maxSize' => 1.8,
'speed' => 1,
'color' => 'foreground',
])
@php
// A decorative full-bleed field of twinkling sparkles. Each glint sits at a
// random position and pulses its opacity in and out (with a tiny drift),
// unlike `particles` which parallaxes the whole field toward the pointer —
// sparkles twinkle in place and never react to the cursor.
//
// The glint colour is read at runtime from the canvas's computed `color`
// (a text-* token, default text-foreground), so theming or a text-* utility
// on the host changes the sparkles — nothing is hard-coded. DPR-aware and
// reduced-motion safe (a single static frame is painted, no 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';
// Clamp sizes and keep max >= min so the spawn range is always valid.
$min = max(0.2, (float) $minSize);
$max = max($min, (float) $maxSize);
$config = [
// how many glints to seed (scaled by box area in JS); clamped for sanity.
'density' => max(1, min(600, (int) $density)),
'minSize' => $min,
'maxSize' => $max,
// speed scales how fast each glint advances through its twinkle pulse.
'speed' => max(0, (float) $speed),
];
@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 glint colour from this element's computed `color`. --}}
<div
data-slot="sparkles"
x-data="uiSparkles(@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>
/**
* Sparkles behavior (a twinkling glint field — opacity pulsing in place).
*
* A single <canvas> fills the parent box. We seed glints across it (count scaled
* by box area so density reads the same on small and large boxes), each at a
* random position, with a random size in [minSize, maxSize] and its own twinkle
* phase + speed. Every frame we:
* 1. clear the canvas;
* 2. advance each glint's phase and set its alpha from a smooth sine pulse, so
* it fades in and out — twinkling IN PLACE (with only a tiny drift), never
* reacting to the pointer (this is what distinguishes it from `particles`,
* which parallaxes the whole field toward the cursor);
* 3. once a glint completes a full fade-out, respawn it at a fresh random
* position with a new phase, so the constellation keeps reshuffling.
*
* Each glint is drawn as a cheap 4-point star (two crossed quads) for a little
* extra sparkle, falling back to nothing heavier than a fill.
*
* Colour is SEMANTIC: the glint 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 sparkles.
*
* 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 (glints at mid-opacity) and no animation loop / rAF 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('uiSparkles', (config = {}) => ({
density: Math.max(1, config.density ?? 80),
minSize: Math.max(0.2, config.minSize ?? 0.6),
maxSize: Math.max(0.2, config.maxSize ?? 1.8),
speed: Math.max(0, config.speed ?? 1),
_ctx: null,
_dpr: 1,
_cssW: 0,
_cssH: 0,
_sparkles: [],
// Resolved from the host's computed `color` token in layout() before any
// draw; the placeholder is never painted.
_color: 'transparent',
// Parsed channels of _color, cached so the per-glint draw never re-parses
// the colour string (it only changes on resolveColor / resize / theme).
_rgb: { r: 0, g: 0, b: 0 },
_raf: null,
_ro: null,
_mo: 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: glints painted at a calm mid-opacity.
this.drawStatic();
return;
}
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 glint colour from the host's computed `color` token.
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 };
},
// How many glints to draw: `density` is per a ~80k px² tile, scaled by the
// actual box area so the field looks equally dense at any size (clamped).
count() {
const area = this._cssW * this._cssH;
const scaled = Math.round((this.density * area) / 80000);
return Math.max(1, Math.min(1200, scaled || this.density));
},
newSparkle() {
const span = this.maxSize - this.minSize;
return {
x: Math.random() * this._cssW,
y: Math.random() * this._cssH,
size: this.minSize + Math.random() * span,
// peak opacity each glint twinkles up to.
peak: 0.4 + Math.random() * 0.6,
// current point in the twinkle cycle and how fast it advances.
phase: Math.random() * Math.PI * 2,
phaseSpeed: (0.01 + Math.random() * 0.02) * this.speed,
// tiny in-place drift so the field breathes without travelling.
dx: (Math.random() - 0.5) * 0.06 * this.speed,
dy: (Math.random() - 0.5) * 0.06 * this.speed,
translateX: 0,
translateY: 0,
alpha: 0,
};
},
seed() {
this._sparkles = [];
const n = this.count();
for (let i = 0; i < n; i++) {
this._sparkles.push(this.newSparkle());
}
},
// 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();
},
// Cheap 4-point star: a filled diamond crossed by a thinner one, so it
// reads as a glint rather than a plain dot. Pure transform/fill, no blur.
drawSparkle(s) {
const ctx = this._ctx;
const { r, g, b } = this._rgb;
const x = s.x + s.translateX;
const y = s.y + s.translateY;
const long = s.size * 2;
const short = s.size * 0.5;
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${s.alpha})`;
// vertical spike
ctx.beginPath();
ctx.moveTo(x, y - long);
ctx.lineTo(x + short, y);
ctx.lineTo(x, y + long);
ctx.lineTo(x - short, y);
ctx.closePath();
ctx.fill();
// horizontal spike
ctx.beginPath();
ctx.moveTo(x - long, y);
ctx.lineTo(x, y + short);
ctx.lineTo(x + long, y);
ctx.lineTo(x, y - short);
ctx.closePath();
ctx.fill();
},
loop() {
const ctx = this._ctx;
ctx.clearRect(0, 0, this._cssW, this._cssH);
for (let i = 0; i < this._sparkles.length; i++) {
const s = this._sparkles[i];
// Advance the twinkle phase and map it to a smooth 0..1 pulse.
s.phase += s.phaseSpeed;
const pulse = (Math.sin(s.phase) + 1) / 2; // 0..1
s.alpha = pulse * s.peak;
// Tiny in-place drift (accumulated as a translate, not recycled
// on leaving the box — glints stay put and twinkle).
s.translateX += s.dx;
s.translateY += s.dy;
this.drawSparkle(s);
// Once a glint has fully faded out at the bottom of its cycle,
// respawn it elsewhere so the constellation keeps reshuffling.
if (pulse < 0.02 && s.phaseSpeed > 0) {
this._sparkles[i] = this.newSparkle();
}
}
this._raf = requestAnimationFrame(() => this.loop());
},
// Reduced motion: a single static field at a calm mid-opacity, no rAF.
drawStatic() {
const ctx = this._ctx;
if (!ctx) return;
ctx.clearRect(0, 0, this._cssW, this._cssH);
for (const s of this._sparkles) {
s.alpha = s.peak * 0.5;
this.drawSparkle(s);
}
},
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;
}
this._sparkles = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.3 - 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