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
Sparkles

Starfield — An interactive 3D hyperspace starfield on a canvas: stars stream outward from the centre and parallax gently toward the pointer, in a semantic token color. DPR aware and reduced-motion safe (static scatter).

Preview

previews.components.starfield.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.starfield :quantity="240" />
</div>

Installation

terminal
php artisan ui:add starfield

Note

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

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

Registry contract

php artisan ui:add starfield 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/starfield.blade.php
  • js resources/js/ui/starfield.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.

starfield.md
# Brok UI: Starfield (`starfield`)

An interactive 3D hyperspace starfield on a canvas: stars stream outward from the centre and parallax gently toward the pointer, in a semantic token color. DPR aware and reduced-motion safe (static scatter).

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

## Install

```bash
php artisan ui:add starfield
```

## 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.starfield :quantity="240" />
</div>
```

## Props

- `quantity` (int, default `200`) — Number of stars in flight, clamped to 600.
- `color` (string, default `foreground`) — Semantic colour token for the stars (foreground, muted, primary, secondary, accent).
- `speed` (int, default `1`) — Scales how fast stars rush toward the viewer per frame.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a hyperspace/warp-speed background behind a hero, loading screen or sci-fi themed 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 a calm drifting field rather than a rushing warp effect; use particles or sparkles instead.

## Anti-patterns

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

## Rules

- Use the `<brok:starfield>` tag (or `<x-ui.starfield>`) 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/starfield
- Registry JSON (files, props, contract): https://brokui.dev/r/open/starfield.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 200 Number of stars in flight, clamped to 600.
color string foreground Semantic colour token for the stars (foreground, muted, primary, secondary, accent).
speed int 1 Scales how fast stars rush toward the viewer per frame.

Slots

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

Data slots

Stable hooks for CSS overrides and browser tests.

starfield

Behavior

  • Streams quantity stars outward from the box centre in a 3D warp effect and gently parallaxes them toward 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 hyperspace/warp-speed background behind a hero, loading screen or sci-fi themed 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 a calm drifting field rather than a rushing warp effect; use particles or sparkles 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
starfield

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 scatter of stars 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.starfield :quantity="240" />
    </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/starfield.blade.php Blade
@props([
    'quantity' => 200,
    'color' => 'foreground',
    'speed' => 1,
])

@php
    // An interactive 3D hyperspace starfield. Stars stream outward from the box
    // centre (a warp effect) and parallax gently toward the pointer. The star
    // colour is read at runtime from the canvas's computed `color` (a text-*
    // token, default text-foreground), so theming/utilities change the stars —
    // nothing is hard-coded. DPR-aware and reduced-motion safe (a single static
    // scatter 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 = [
        // number of stars in flight; clamped to keep the rAF loop cheap.
        'quantity' => max(1, min(600, (int) $quantity)),
        // speed scales how fast stars rush toward the viewer per frame.
        '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 star colour from this element's computed `color`. --}}
<div
    data-slot="starfield"
    x-data="uiStarfield(@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/starfield.js JS
/**
 * Starfield behavior (interactive 3D hyperspace warp field with pointer parallax).
 *
 * A single <canvas> fills the parent box. `quantity` stars are seeded in a unit
 * cube — each star has x,y in [-1, 1] and a depth z in (0, 1]. Every frame we:
 *   1. clear the canvas;
 *   2. advance each star toward the viewer (z -= speed * 0.005); a star that
 *      passes the near plane (z <= 0) is recycled to a fresh random star at z = 1,
 *      so the field streams outward from the centre forever;
 *   3. project to screen with a simple perspective divide — `sx = cx + x/z * scale`
 *      — so a star's apparent position races away from centre and its radius grows
 *      as z shrinks (the classic warp look);
 *   4. ease the centre toward a fraction of the pointer offset, giving the whole
 *      field a gentle parallax around the cursor; alpha fades up as a star nears,
 *      down as it is born far away.
 *
 * Colour is SEMANTIC: the star 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 stars.
 *
 * 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
 * (a calm star scatter) 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('uiStarfield', (config = {}) => ({
        quantity: Math.max(1, config.quantity ?? 200),
        speed: Math.max(0, config.speed ?? 1),

        _ctx: null,
        _dpr: 1,
        _cssW: 0,
        _cssH: 0,
        _stars: [],
        // 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-star draw never re-parses
        // the colour string (it only changes on resolveColor / resize / theme).
        _rgb: { r: 0, g: 0, b: 0 },
        // Pointer offset (eased) and the resulting parallax shift of the centre.
        _pointer: { x: 0, y: 0 },
        _parallax: { 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: a calm scatter painted at each star's depth.
                this.drawStatic();
                return;
            }

            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 star 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 };
        },

        // Pointer position relative to the box centre, normalized to roughly
        // [-0.5, 0.5] on each axis — what the parallax centre eases toward.
        trackPointer(e) {
            const rect = this.$refs.root.getBoundingClientRect();
            const x = (e.clientX - rect.left) / Math.max(1, this._cssW) - 0.5;
            const y = (e.clientY - rect.top) / Math.max(1, this._cssH) - 0.5;
            const inside = x >= -0.5 && x <= 0.5 && y >= -0.5 && y <= 0.5;
            if (inside) {
                this._pointer.x = x;
                this._pointer.y = y;
            }
        },

        // A fresh star in the unit cube. `born` lets us fade it in from far away.
        newStar(z) {
            return {
                x: Math.random() * 2 - 1,
                y: Math.random() * 2 - 1,
                z: z ?? Math.random() * 0.9 + 0.1,
            };
        },

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

        // 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();
        },

        // Project a star to screen space. `scale` is tied to the box size so the
        // warp reads the same at any dimension; the perspective divide by z makes
        // near stars (small z) fling toward the edges and grow.
        project(star) {
            const scale = Math.min(this._cssW, this._cssH) * 0.5;
            // Parallax: shift the vanishing point by a small fraction of the
            // pointer offset so the whole field leans toward the cursor.
            const cx = this._cssW / 2 + this._parallax.x;
            const cy = this._cssH / 2 + this._parallax.y;
            const sx = cx + (star.x / star.z) * scale;
            const sy = cy + (star.y / star.z) * scale;
            // Radius grows as the star approaches; alpha fades in from afar.
            const radius = Math.max(0.4, (1 - star.z) * 2.4);
            const alpha = Math.min(1, (1 - star.z) * 1.2 + 0.1);
            return { sx, sy, radius, alpha };
        },

        drawStar(star) {
            const ctx = this._ctx;
            const { sx, sy, radius, alpha } = this.project(star);
            // Skip stars projected outside the visible box (cheap cull).
            if (sx < -radius || sx > this._cssW + radius || sy < -radius || sy > this._cssH + radius) {
                return;
            }
            const { r, g, b } = this._rgb;
            ctx.beginPath();
            ctx.arc(sx, sy, radius, 0, Math.PI * 2);
            ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
            ctx.fill();
        },

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

            // Ease the parallax centre toward the pointer offset (gentle catch-up).
            const reach = Math.min(this._cssW, this._cssH) * 0.08;
            this._parallax.x += (this._pointer.x * reach - this._parallax.x) * 0.05;
            this._parallax.y += (this._pointer.y * reach - this._parallax.y) * 0.05;

            const dz = this.speed * 0.005;
            for (let i = 0; i < this._stars.length; i++) {
                const star = this._stars[i];
                star.z -= dz;
                // Passed the near plane → recycle to a fresh star at the far edge.
                if (star.z <= 0) {
                    this._stars[i] = this.newStar(1);
                    continue;
                }
                this.drawStar(star);
            }

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

        // Reduced motion: a single static scatter, no parallax, no rAF.
        drawStatic() {
            const ctx = this._ctx;
            if (!ctx) return;
            ctx.clearRect(0, 0, this._cssW, this._cssH);
            this._parallax.x = 0;
            this._parallax.y = 0;
            for (const star of this._stars) {
                this.drawStar(star);
            }
        },

        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._stars = [];
        },
    }));
});

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