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.0.3
Stability
stable
License
MIT
Related
Particles
Starfield

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

Preview

Sparkles
previews.components.sparkles.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.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

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/sparkles.blade.php
  • js resources/js/ui/sparkles.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.

sparkles.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

sparkles

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

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 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
root
Theming hooks
sparkles

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

resources/views/components/ui/sparkles.blade.php Blade
@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>
resources/js/ui/sparkles.js JS
/**
 * 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