Particle Fields
Ambient canvas particle backdrops — a drifting pointer-parallax field, twinkling sparkles, and a 3D hyperspace 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).
Preview
<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
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:
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.
-
resources/views/components/ui/starfield.blade.php -
resources/js/ui/starfield.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: 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
Props
| 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.
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
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
- 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 scatter of stars 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.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.
@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>
/**
* 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