Skip to content
Brok UI

Loading…

No results

Pixel Canvas

Open source

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.

Version
v1.3.3
Stability
stable
License
MIT
Related
Particles
Sparkles
Dot Pattern

Preview

Move your pointer over this card

Variant
previews.components.pixel-canvas.default.blade.php 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>
Default Current
Trail Current
Glow Current

Installation

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

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

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

pixel-canvas.md
# 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

variants.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

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

pixel-canvas

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

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 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
root
Theming hooks
pixel-canvas

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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>
    {{-- 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.

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