Pixel Canvas
A decorative canvas grid of square pixels that light up under the cursor and smoothly fade back — or, with the idle + twinkle props, an ambient pixel-stars field, or with the flow prop, autonomous organic shapes (a drifting metaball field) rendered as pixels. Token-derived colors, default/trail/glow variants.
Preview
Move your pointer over this card
{{-- The default grid is cursor-only (idle 0), so the card is empty until the
pointer moves over it; the label says so instead of showing a blank box. --}}
<div class="relative flex h-56 w-full max-w-md items-center justify-center overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas />
<p class="relative text-sm text-muted-foreground">{{ __('Move your pointer over this card') }}</p>
</div>
Installation
php artisan ui:add pixel-canvas
Note
This component ships an Alpine behavior module at
resources/js/ui/pixel-canvas.js. Import it once from your bundle so it registers on alpine:init:
import './pixel-canvas.js';
Registry contract
php artisan ui:add pixel-canvas
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/pixel-canvas.blade.php -
resources/js/ui/pixel-canvas.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: Pixel Canvas (`pixel-canvas`)
A decorative canvas grid of square pixels that light up under the cursor and smoothly fade back — or, with the idle + twinkle props, an ambient pixel-stars field, or with the flow prop, autonomous organic shapes (a drifting metaball field) rendered as pixels. Token-derived colors, default/trail/glow variants.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add pixel-canvas
```
## Usage
```blade
{{-- The default grid is cursor-only (idle 0), so the card is empty until the
pointer moves over it; the label says so instead of showing a blank box. --}}
<div class="relative flex h-56 w-full max-w-md items-center justify-center overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas />
<p class="relative text-sm text-muted-foreground">{{ __('Move your pointer over this card') }}</p>
</div>
```
## Props
- `gap` (int, default `6`) — Pixel cell spacing in pixels; larger reads sparser. A chunkier gap (12-16) suits the flow variant.
- `speed` (int, default `35`) — How quickly an excited pixel fades back to rest; higher is faster.
- `variant` (default|trail|glow, default `default`) — Visual style: default (sharp squares), trail (rounded pixels) or glow (soft blur).
- `colors` (mixed|null, default `null`) — Explicit array of colours to interpolate across; when null the palette is derived at runtime from semantic tokens.
- `idle` (int, default `0`) — Resting intensity (0-1) for the whole grid; a small value leaves a faint always-visible dot grid, the base of a pixel-stars look.
- `twinkle` (int, default `0`) — Ambient twinkles per second; a positive value lights random pixels independent of the cursor, like stars.
- `flow` (int, default `0`) — Autonomous drifting-metaball field strength; 0 is off, a positive value scales the drift pace (1 = calm).
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding an interactive pixel grid that lights up under the cursor behind a hero, card or panel.
- Wanting one component that can also stand in for an ambient pixel-stars field (idle + twinkle) or a drifting organic-shape field (flow), instead of authoring three separate effects.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a smooth continuous dot field instead of a discrete pixel grid; 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:pixel-canvas>` tag (or `<x-ui.pixel-canvas>`) 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/pixel-canvas
- Registry JSON (files, props, contract): https://brokui.dev/r/open/pixel-canvas.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Opt-in props turn the cursor-reactive grid into two ambient effects, both
default off so the cursor-only look is unchanged: idle + twinkle give a
"pixel stars" field (uicapsule.com "background pixels"), and flow gives
autonomous drifting organic shapes (uicapsule.com "background shapes"). --}}
<div class="grid w-full gap-6 sm:grid-cols-2">
{{-- Ambient stars: a faint resting dot-grid (idle) with random cells twinkling
in token palette colors (twinkle), independent of the cursor. A low speed
lets each glint linger before fading. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas :gap="10" :speed="14" :idle="0.06" :twinkle="18" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Ambient stars — idle grid + twinkle') }}</figcaption>
</figure>
{{-- Denser, faster twinkle over a brighter resting grid, glow variant for a
soft bloom on each lit cell. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas variant="glow" :gap="8" :speed="20" :idle="0.1" :twinkle="40" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Dense glow twinkle') }}</figcaption>
</figure>
{{-- Twinkle with no resting floor: stars glint and fade to black, no base grid. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas :gap="12" :speed="10" :twinkle="12" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Twinkle only — no resting grid') }}</figcaption>
</figure>
{{-- Idle only: a calm, always-visible dot-grid that still lights under the
cursor (hover it), with no autonomous twinkle. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas :gap="10" :speed="30" :idle="0.08" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Idle grid only — hover to light') }}</figcaption>
</figure>
{{-- The `flow` prop turns the same grid into autonomous, drifting organic
shapes — a metaball field sampled per cell (uicapsule.com "background
shapes"). Reads best with a chunkier gap. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas :gap="14" :flow="1" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Flowing shapes — drifting metaball field') }}</figcaption>
</figure>
{{-- Flowing shapes over a faint resting grid (idle), so the pixels read even
where the blobs are absent — the full "background shapes" look. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas :gap="16" :flow="1.4" :idle="0.05" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Shapes over a faint resting grid') }}</figcaption>
</figure>
{{-- Glow variant: each lit cell blooms, giving the shapes a hazy edge. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas variant="glow" :gap="14" :flow="1" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Glow shapes') }}</figcaption>
</figure>
{{-- All three layers at once: flowing shapes + ambient twinkle + cursor light
(hover) composed over one grid. --}}
<figure class="space-y-2">
<div class="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas :gap="12" :flow="1.2" :twinkle="14" :idle="0.04" />
</div>
<figcaption class="text-xs text-muted-foreground">{{ __('Shapes + twinkle + hover') }}</figcaption>
</figure>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| gap | int | 6 | Pixel cell spacing in pixels; larger reads sparser. A chunkier gap (12-16) suits the flow variant. |
| speed | int | 35 | How quickly an excited pixel fades back to rest; higher is faster. |
| variant | default | trail | glow | default | Visual style: default (sharp squares), trail (rounded pixels) or glow (soft blur). |
| colors | mixed | null | null | Explicit array of colours to interpolate across; when null the palette is derived at runtime from semantic tokens. |
| idle | int | 0 | Resting intensity (0-1) for the whole grid; a small value leaves a faint always-visible dot grid, the base of a pixel-stars look. |
| twinkle | int | 0 | Ambient twinkles per second; a positive value lights random pixels independent of the cursor, like stars. |
| flow | int | 0 | Autonomous drifting-metaball field strength; 0 is off, a positive value scales the drift pace (1 = calm). |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Tracks the pointer over a canvas grid of cells sized by gap, exciting nearby pixels and fading them back to rest at speed; idle, twinkle and flow layer optional autonomous motion on top of and independent from the cursor effect.
- Pixel colours default to a palette derived from semantic tokens (primary → accent → foreground) unless colors is set explicitly.
- 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 interactive pixel grid that lights up under the cursor behind a hero, card or panel.
- Wanting one component that can also stand in for an ambient pixel-stars field (idle + twinkle) or a drifting organic-shape field (flow), instead of authoring three separate effects.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a smooth continuous dot field instead of a discrete pixel grid; 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 canvas is aria-hidden and pointer-events-none, so it never traps focus or blocks interaction with content layered above it.
- Reduced motion renders one calm, low-intensity static grid frame with no pointer tracking or 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>
{{-- The default grid is cursor-only (idle 0), so the card is empty until the
pointer moves over it; the label says so instead of showing a blank box. --}}
<div class="relative flex h-56 w-full max-w-md items-center justify-center overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.pixel-canvas />
<p class="relative text-sm text-muted-foreground">{{ __('Move your pointer over this card') }}</p>
</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([
// px spacing between pixel cell centers (cell size factor). Larger = sparser.
'gap' => 6,
// Fade speed: how quickly an excited pixel decays back to rest (higher = faster).
'speed' => 35,
// Visual style: default (sharp squares) | trail (rounded pixels) | glow (soft blur).
'variant' => 'default',
// Optional array of explicit colors to interpolate across. When null the
// palette is derived at runtime from semantic tokens (primary → accent →
// foreground) read off the root element.
'colors' => null,
// Resting intensity (0..1) for the whole grid: 0 keeps it invisible until the
// cursor lights it (default); a small value (e.g. 0.06) leaves a faint dot
// grid always visible — the base of an ambient "pixel stars" field.
'idle' => 0,
// Ambient twinkles per second: 0 is the cursor-only effect (default); a
// positive value lights random pixels in random palette colors that fade like
// stars, independent of the cursor. Pair with `idle` for the stars look.
'twinkle' => 0,
// Autonomous flowing-shapes field: 0 is off (default); a positive value
// turns the grid into drifting organic "blobs" (a metaball field) rendered
// as pixels — soft shapes that slowly morph and float across the box,
// colored across the palette by their intensity. The value scales the drift
// pace (1 = calm). Composes with the cursor + twinkle layers. Reads best
// with a chunkier `gap` (e.g. 12–16).
'flow' => 0,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$variant = in_array($variant, ['default', 'trail', 'glow'], true) ? $variant : 'default';
$palette = is_array($colors) ? array_values(array_filter($colors, fn ($c) => is_string($c) && trim($c) !== '')) : [];
$config = [
// gap is also the cell size; clamp so a cell is always at least 1px.
'gap' => max(2, (int) $gap),
'speed' => max(1, (int) $speed),
'variant' => $variant,
// Empty array => behavior layer derives colors from token palette.
'colors' => $palette,
// Clamp idle to 0..1; twinkle floored at 0 (spawns/sec).
'idle' => max(0, min(1, (float) $idle)),
'twinkle' => max(0, (float) $twinkle),
// Flow floored at 0 (drift-pace multiplier; 0 disables the field).
'flow' => max(0, (float) $flow),
];
@endphp
{{-- Decorative fill layer. The canvas is aria-hidden and pointer-events-none so
it never traps focus or blocks interaction with content layered above it.
Pixel colors derive from the root's semantic tokens (default: --primary),
so the effect follows the active theme. Reduced motion renders one calm,
low-intensity static grid frame with no pointer/rAF churn. --}}
<div
data-slot="pixel-canvas"
x-data="uiPixelCanvas(@js($config))"
x-ref="root"
aria-hidden="true"
{{ $attributes->merge(['class' => 'pointer-events-none absolute inset-0 text-primary']) }}
>
<canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
/**
* Pixel Canvas behavior (cursor-reactive pixel grid, optional ambient twinkle).
*
* Fills the parent box with a canvas, then lays out a grid of square "pixels"
* spaced `gap` px apart (gap is also the cell size). Every requestAnimationFrame
* frame each pixel's `intensity` (0..1) decays toward 0; on `pointermove` any
* pixel within a radius of the cursor is bumped back toward 1. A pixel's color
* is interpolated across the `colors` array by its intensity, and it is drawn as
* a filled rect — rounded for the `trail` variant, with a soft shadow blur for
* `glow`. So moving the cursor lights up a soft cluster of pixels that smoothly
* fade out behind it.
*
* Two opt-in knobs turn the same grid into an ambient "pixel stars" field
* (both default off, so the cursor-only behavior is unchanged):
* - `idle` (0..1): a resting intensity floor so the whole grid stays faintly
* visible (a dim dot-grid) even with no cursor — drawn in the neutral
* foreground stop.
* - `twinkle` (>0): spawns that many random pixels per second, each lit to
* full and assigned a random palette color, then left to decay — so cells
* light up and fade like stars, independent of the cursor. The two layers
* compose: a cursor still lights its cluster over the twinkling field.
*
* A third opt-in knob renders autonomous flowing SHAPES on the same grid:
* - `flow` (>0): maintains a handful of drifting "blob" centers and samples a
* metaball field (sum of inverse-square falloffs) at every cell each frame.
* Cells inside the field light up across the palette by their field value,
* so soft organic shapes slowly morph and float through the pixel grid (the
* uicapsule.com "background shapes" look). The value scales the drift pace.
* It layers beneath the cursor + twinkle, so all three can run at once.
*
* Color is SEMANTIC: when no explicit `colors` are passed we derive the palette
* at runtime from the root element's tokens — its computed `color` (default
* currentColor = text-primary) plus the `--primary`, `--accent` and
* `--foreground` custom properties when resolvable — so pixels follow the active
* theme rather than hard-coded values.
*
* Sizing: the canvas backing store is scaled for devicePixelRatio and a
* ResizeObserver rebuilds the grid when the box changes, keeping pixels crisp.
*
* a11y: the host is aria-hidden and pointer-events-none (set in Blade); this is
* a purely decorative fill layer. Reduced motion: a single calm, low-intensity
* static grid frame is painted with no pointer listeners and no animation loop.
*
* 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('uiPixelCanvas', (config = {}) => ({
gap: Math.max(2, config.gap ?? 6),
// Fade speed as a per-frame decay fraction (normalized from the 0..100ish prop).
speed: Math.max(1, config.speed ?? 35),
variant: config.variant ?? 'default',
// Explicit colors override token derivation when non-empty.
colors: Array.isArray(config.colors) ? config.colors.filter((c) => typeof c === 'string' && c.trim() !== '') : [],
// Resting intensity floor (0..1): keeps the grid faintly visible at idle.
idle: Math.min(1, Math.max(0, config.idle ?? 0)),
// Ambient twinkles spawned per second (0 = cursor-only, original behavior).
twinkle: Math.max(0, config.twinkle ?? 0),
// Flowing-shapes drift pace (0 = no field, original behavior).
flow: Math.max(0, config.flow ?? 0),
_ctx: null,
_dpr: 1,
_palette: [],
_pixels: [],
_cols: 0,
_rows: 0,
// Radius (px) of cursor influence; derived from gap so density-independent.
_radius: 0,
_cursor: { x: -9999, y: -9999, active: false },
_raf: null,
_ro: null,
_onMove: null,
_onLeave: null,
_reduced: false,
// Neutral resting color for the idle grid (foreground stop); set in build().
_restColor: null,
// Fractional twinkle-spawn accumulator + last frame timestamp.
_spawnAcc: 0,
_lastTs: 0,
// Flowing-shapes field: drifting blob centers, accumulated field time,
// and the CSS box size (cached so the metaball math stays in CSS px).
_blobs: [],
_flowT: 0,
_cssW: 0,
_cssH: 0,
_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.build();
if (this._reduced) {
// Static frame only: low, even intensity, no listeners, no rAF.
// In twinkle mode, seed a calm scatter of lit pixels once so the
// starfield still reads — but it never moves.
if (this.twinkle > 0) this.seedStatic();
// In flow mode, sample the blob field once at t=0 so the shapes
// still read as a single frozen frame, with no animation loop.
if (this.flow > 0) this.computeFlow(0);
this.drawStatic();
return;
}
this.draw();
this._onMove = (e) => {
const rect = canvas.getBoundingClientRect();
this._cursor.x = e.clientX - rect.left;
this._cursor.y = e.clientY - rect.top;
this._cursor.active = true;
};
this._onLeave = () => { this._cursor.active = false; };
// The host is pointer-events-none (so it never blocks content layered
// above it), so listen on the parent container — the element this
// canvas fills. That makes it react to the cursor both standalone and
// when used as a background behind other content.
this._listenTarget = this.$refs.root.parentElement || this.$refs.root;
this._listenTarget.addEventListener('pointermove', this._onMove);
this._listenTarget.addEventListener('pointerleave', this._onLeave);
// Rebuild the grid on resize (debounced to a frame) so cells stay crisp.
let pending = false;
this._ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
this.build();
if (this._reduced) this.drawStatic();
});
});
this._ro.observe(this.$refs.root);
this._raf = requestAnimationFrame((t) => this.loop(t));
// 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((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
// Resolve a CSS color string from a token. Tries the custom property as a
// raw color first, then wraps it so values like `oklch(...)` or bare
// channel lists both work; falls back to null when unresolvable.
resolveToken(name, root) {
const cs = getComputedStyle(root);
const raw = cs.getPropertyValue(name).trim();
if (raw === '') return null;
// Probe the value through a scratch element to normalize to rgb().
const probe = document.createElement('span');
probe.style.color = raw;
// Some tokens store bare channels (e.g. "222 47% 11%" for hsl, or
// "0.6 0.1 250" for oklch) meant for a color function; if the bare
// value didn't take, try common wrappers. hsl() is tried first because
// this design system stores tokens as bare HSL channels.
if (probe.style.color === '') {
probe.style.color = `hsl(${raw})`;
if (probe.style.color === '') probe.style.color = `oklch(${raw})`;
if (probe.style.color === '') probe.style.color = `rgb(${raw})`;
}
if (probe.style.color === '') return null;
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color;
document.body.removeChild(probe);
return resolved && resolved !== '' ? resolved : null;
},
// Build the interpolation palette: explicit colors if given, else derive
// from semantic tokens (primary → accent → foreground), defaulting to the
// root's computed `color` (currentColor = text-primary) so it always has
// at least one usable color.
resolvePalette() {
if (this.colors.length > 0) {
this._palette = this.colors.slice();
return;
}
const root = this.$refs.root;
const current = getComputedStyle(root).color || 'currentColor';
const palette = [];
const primary = this.resolveToken('--primary', root);
const accent = this.resolveToken('--accent', root);
const foreground = this.resolveToken('--foreground', root);
palette.push(primary || current);
if (accent) palette.push(accent);
palette.push(foreground || current);
this._palette = palette;
},
// Parse a CSS color string into [r,g,b] via a scratch canvas (handles any
// format the browser understands: oklch, hsl, named, hex, rgb).
toRgb(color) {
const c = this._ctx;
c.save();
c.fillStyle = 'CanvasText';
c.fillStyle = color;
const computed = c.fillStyle; // normalized to #rrggbb or rgba(...)
c.restore();
if (computed.startsWith('#')) {
const hex = computed.slice(1);
const full = hex.length === 3 ? hex.split('').map((h) => h + h).join('') : hex;
return [
parseInt(full.slice(0, 2), 16),
parseInt(full.slice(2, 4), 16),
parseInt(full.slice(4, 6), 16),
];
}
const m = computed.match(/(\d+(?:\.\d+)?)/g);
return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [0, 0, 0];
},
// Build the pixel grid sized to the box, scaled for DPR, and precompute
// the RGB stops used for per-pixel color interpolation.
build() {
const canvas = this.$refs.canvas;
const root = this.$refs.root;
this._dpr = Math.max(1, window.devicePixelRatio || 1);
const cssW = Math.max(1, root.clientWidth);
const cssH = Math.max(1, root.clientHeight);
// Cache the CSS box size so the flow-field math runs in CSS px.
this._cssW = cssW;
this._cssH = cssH;
canvas.width = Math.round(cssW * this._dpr);
canvas.height = Math.round(cssH * this._dpr);
this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
this.resolvePalette();
this._stops = this._palette.map((c) => this.toRgb(c));
if (this._stops.length === 0) this._stops = [[0, 0, 0]];
// Idle grid color: the neutral foreground stop (last), so the resting
// dot-grid reads as a calm theme-colored mesh, not a tinted one.
const restStop = this._stops[this._stops.length - 1];
this._restColor = `rgb(${restStop[0]},${restStop[1]},${restStop[2]})`;
// Cursor influence radius scales with cell size so the lit cluster
// stays visually consistent across gap values.
this._radius = this.gap * 6;
const cell = this.gap;
this._cols = Math.ceil(cssW / cell);
this._rows = Math.ceil(cssH / cell);
const pixels = [];
for (let row = 0; row < this._rows; row++) {
for (let col = 0; col < this._cols; col++) {
pixels.push({
x: col * cell,
y: row * cell,
intensity: 0,
// Color assigned when this pixel twinkles; null = use the
// intensity-interpolated palette (cursor path).
color: null,
// Flow-field value (0..1) recomputed each frame in flow mode.
flow: 0,
});
}
}
this._pixels = pixels;
// (Re)seed the drifting blob centers for the flow field, sized to the
// current box, when flow mode is on. Done on every build so the field
// rescales with the box on resize.
if (this.flow > 0) this.buildBlobs();
},
// Create a small set of drifting "blob" centers for the metaball field.
// Each blob orbits a slowly over an elliptical path (sin/cos of the field
// clock) and carries a squared radius used by the inverse-square falloff.
// Positions/speeds are randomized per instance so no two fields match.
buildBlobs() {
const minDim = Math.min(this._cssW, this._cssH);
const count = 4;
const blobs = [];
for (let i = 0; i < count; i++) {
const r = minDim * (0.12 + Math.random() * 0.1);
blobs.push({
// Anchor (fraction of the box) the blob orbits around.
bx: 0.18 + Math.random() * 0.64,
by: 0.18 + Math.random() * 0.64,
// Orbit amplitude (fraction of the box).
ax: 0.1 + Math.random() * 0.22,
ay: 0.1 + Math.random() * 0.22,
// Angular speeds (rad/sec-ish) + phase offsets for variety.
fx: 0.1 + Math.random() * 0.18,
fy: 0.1 + Math.random() * 0.18,
px: Math.random() * Math.PI * 2,
py: Math.random() * Math.PI * 2,
r2: r * r,
// Live center, set each frame by computeFlow().
cx: 0,
cy: 0,
});
}
this._blobs = blobs;
},
// Sample the metaball field at `time` and write each pixel's `flow` value.
// Blob centers are advanced to `time` first, then every cell sums an
// inverse-square falloff from each blob; the total is smoothstep-mapped
// to 0..1 so shapes have soft, anti-aliased edges across the grid.
computeFlow(time) {
const blobs = this._blobs;
if (!blobs || blobs.length === 0) return;
const W = this._cssW;
const H = this._cssH;
for (const b of blobs) {
b.cx = (b.bx + b.ax * Math.sin(time * b.fx + b.px)) * W;
b.cy = (b.by + b.ay * Math.cos(time * b.fy + b.py)) * H;
}
const half = this.gap / 2;
// Field thresholds: below `lo` is empty, above `hi` is "solid". The
// band is kept wide and the ceiling capped so shapes read as a soft
// gradient (token primary → accent) with bright rims, rather than
// saturating large interiors to the white foreground stop.
const lo = 0.9;
const hi = 2.4;
const span = hi - lo;
// Cap the brightest cells below 1 so cores stay in the palette's
// mid-range (accent), not the foreground/white top stop.
const ceiling = 0.78;
for (const p of this._pixels) {
const x = p.x + half;
const y = p.y + half;
let f = 0;
for (const b of blobs) {
const dx = x - b.cx;
const dy = y - b.cy;
// +0.18 * r2 keeps the falloff finite at the center and softens it.
f += b.r2 / (dx * dx + dy * dy + b.r2 * 0.18);
}
let t = (f - lo) / span;
t = t < 0 ? 0 : t > 1 ? 1 : t;
// Smoothstep for soft edges, then scale to the capped ceiling.
p.flow = t * t * (3 - 2 * t) * ceiling;
}
},
// Interpolate the palette stops by t (0..1) → "rgb(r,g,b)".
colorAt(t) {
const stops = this._stops;
if (stops.length === 1) {
const [r, g, b] = stops[0];
return `rgb(${r},${g},${b})`;
}
const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
const scaled = clamped * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(scaled));
const f = scaled - i;
const a = stops[i];
const b = stops[i + 1];
const r = Math.round(a[0] + (b[0] - a[0]) * f);
const g = Math.round(a[1] + (b[1] - a[1]) * f);
const bl = Math.round(a[2] + (b[2] - a[2]) * f);
return `rgb(${r},${g},${bl})`;
},
// A random palette color for a twinkle, as "rgb(r,g,b)". Picks any stop;
// with the default token palette that gives primary/accent/foreground
// glints, and any explicit `colors` array gives that exact set.
twinkleColor() {
const stops = this._stops;
const s = stops[Math.floor(Math.random() * stops.length)] || stops[0];
return `rgb(${s[0]},${s[1]},${s[2]})`;
},
// Light one random pixel to (near) full and give it a random color.
spawnTwinkle() {
if (this._pixels.length === 0) return;
const p = this._pixels[Math.floor(Math.random() * this._pixels.length)];
p.intensity = 0.7 + Math.random() * 0.3;
p.color = this.twinkleColor();
},
// Reduced-motion: scatter a fixed, calm set of lit pixels once (no rAF).
seedStatic() {
const count = Math.max(1, Math.round(this._pixels.length / 90));
for (let i = 0; i < count; i++) {
const p = this._pixels[Math.floor(Math.random() * this._pixels.length)];
p.intensity = 0.4 + Math.random() * 0.4;
p.color = this.twinkleColor();
}
},
loop(ts) {
// Time delta (seconds) for frame-rate-independent twinkle spawning.
const now = typeof ts === 'number' ? ts : 0;
const dt = this._lastTs ? Math.min(0.1, (now - this._lastTs) / 1000) : 0;
this._lastTs = now;
if (this.twinkle > 0 && dt > 0) {
this._spawnAcc += this.twinkle * dt;
while (this._spawnAcc >= 1) {
this.spawnTwinkle();
this._spawnAcc -= 1;
}
}
// Advance the flow-field clock and resample the blob field so the
// shapes drift. `flow` scales the pace; this writes each pixel's
// `flow` value, independent of the cursor/twinkle `intensity` below.
if (this.flow > 0) {
this._flowT += dt * this.flow;
this.computeFlow(this._flowT);
}
// Per-frame decay fraction. The prop is ~1..100; normalize so higher
// speed fades faster while staying stable across frame rates.
const decay = this.speed / 1000;
const r = this._radius;
const r2 = r * r;
const active = this._cursor.active;
const cx = this._cursor.x;
const cy = this._cursor.y;
const cell = this.gap;
const half = cell / 2;
for (const p of this._pixels) {
if (active) {
const dx = (p.x + half) - cx;
const dy = (p.y + half) - cy;
const d2 = dx * dx + dy * dy;
if (d2 < r2) {
// Bump toward 1, strongest at the cursor center. Cursor
// light uses the interpolated palette, so drop any twinkle
// color this cell may have been carrying.
const boost = 1 - Math.sqrt(d2) / r;
if (boost > p.intensity) {
p.intensity = boost;
p.color = null;
}
}
}
// Smooth fade back to rest.
p.intensity -= decay;
if (p.intensity <= 0) {
p.intensity = 0;
p.color = null;
}
}
this.draw();
this._raf = requestAnimationFrame((t) => this.loop(t));
},
// Shared pixel renderer. `min` floors the drawn intensity so the static
// (reduced-motion) frame shows a calm, visible grid.
render(min) {
const ctx = this._ctx;
if (!ctx) return;
const w = this.$refs.canvas.width / this._dpr;
const h = this.$refs.canvas.height / this._dpr;
ctx.clearRect(0, 0, w, h);
const cell = this.gap;
// Pixel square is a fraction of the cell so a grid gap shows through.
const size = Math.max(1, cell - 2);
const rounded = this.variant === 'trail';
const glow = this.variant === 'glow';
const radius = rounded ? Math.min(size / 2, 3) : 0;
// Resting floor: max of the explicit `idle` prop and any reduced-motion
// `min`. Pixels sitting at the floor (not actively lit) render in the
// neutral foreground color so the idle grid reads as a calm mesh.
const floor = Math.max(min, this.idle);
for (const p of this._pixels) {
const lit = p.intensity;
// Flow shapes contribute their own intensity beneath the cursor.
const fl = p.flow;
const t = Math.max(floor, lit, fl);
if (t <= 0) continue;
ctx.globalAlpha = Math.min(1, t);
// Twinkling cells keep their assigned color; cursor-lit and
// flow-shape cells use the intensity-interpolated palette; idle-
// floor cells use the neutral rest color.
if (lit > 0 && p.color) {
ctx.fillStyle = p.color;
} else if (lit > 0 || fl > 0) {
ctx.fillStyle = this.colorAt(t);
} else if (this.idle > 0 && this._restColor) {
ctx.fillStyle = this._restColor;
} else {
// No idle grid configured: preserve the original palette draw
// (this path only runs for reduced-motion's static floor).
ctx.fillStyle = this.colorAt(t);
}
if (glow) {
ctx.shadowColor = ctx.fillStyle;
ctx.shadowBlur = size * 1.5 * t;
} else {
ctx.shadowBlur = 0;
}
if (rounded && ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(p.x, p.y, size, size, radius);
ctx.fill();
} else {
ctx.fillRect(p.x, p.y, size, size);
}
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
},
// Animated frame: draw only the currently-excited pixels.
draw() {
this.render(0);
},
// Reduced-motion frame: a single calm grid at a low, even intensity.
drawStatic() {
this.render(0.12);
},
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; }
const target = this._listenTarget || this.$refs.root;
if (this._onMove) target.removeEventListener('pointermove', this._onMove);
if (this._onLeave) target.removeEventListener('pointerleave', this._onLeave);
this._onMove = null;
this._onLeave = null;
this._pixels = [];
this._blobs = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.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