Skip to content
Brok UI

Loading…

No results

Dot Matrix

Open source

An animated LED dot-matrix display: a grid of glowing pixels in a square, circular, triangle or hex panel, with 17 built-in animations (wave, pulse, vu, loader, bars, orbit, spiral, radar, ripple, helix, snake, rain, comet, scan, pinwheel, twinkle, heart) or a static custom pattern. Single-token colour with opacity-driven lighting, reduced-motion safe.

Version
v1.1.3
Stability
stable
License
MIT
Related
Dots Loader
Matrix Rain
Gradient Bars

Preview

Wave

VU meter

Loader

previews.components.dot-matrix.default.blade.php Blade
<div class="flex flex-wrap items-end gap-10">
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="wave" :rows="7" :cols="15" />
        <p class="text-xs text-muted-foreground">{{ __('Wave') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="vu" :rows="7" :cols="9" />
        <p class="text-xs text-muted-foreground">{{ __('VU meter') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="loader" :rows="7" :cols="7" color="success" />
        <p class="text-xs text-muted-foreground">{{ __('Loader') }}</p>
    </div>
</div>

Installation

terminal
php artisan ui:add dot-matrix

Note

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

resources/js/ui/index.js JS
import './dot-matrix.js';

Registry contract

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

dot-matrix.md
# Brok UI: Dot Matrix (`dot-matrix`)

An animated LED dot-matrix display: a grid of glowing pixels in a square, circular, triangle or hex panel, with 17 built-in animations (wave, pulse, vu, loader, bars, orbit, spiral, radar, ripple, helix, snake, rain, comet, scan, pinwheel, twinkle, heart) or a static custom pattern. Single-token colour with opacity-driven lighting, reduced-motion safe.

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

## Install

```bash
php artisan ui:add dot-matrix
```

## Usage

```blade
<div class="flex flex-wrap items-end gap-10">
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="wave" :rows="7" :cols="15" />
        <p class="text-xs text-muted-foreground">{{ __('Wave') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="vu" :rows="7" :cols="9" />
        <p class="text-xs text-muted-foreground">{{ __('VU meter') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="loader" :rows="7" :cols="7" color="success" />
        <p class="text-xs text-muted-foreground">{{ __('Loader') }}</p>
    </div>
</div>
```

## Props

- `rows` (int, default `7`) — Number of rows in the dot grid, clamped between 1 and 32.
- `cols` (int, default `15`) — Number of columns in the dot grid, clamped between 1 and 64.
- `preset` (string, default `wave`) — Built-in animation generated client-side, such as wave, pulse, vu, or loader.
- `shape` (string, default `square`) — Mask applied to the grid: square, circular, triangle, or hex.
- `fps` (int, default `12`) — Animation frame rate, clamped between 1 and 60.
- `size` (int, default `8`) — Pixel size of each dot.
- `gap` (int, default `4`) — Pixel gap between dots.
- `autoplay` (bool, default `true`) — Whether the animation starts automatically.
- `loop` (bool, default `true`) — Whether the animation repeats after finishing.
- `pattern` (mixed|null, default `null`) — Static 2D array of 0..1 brightness values that freezes a custom glyph instead of animating a preset.
- `color` (string, default `primary`) — Semantic token driving the lit dot color and glow, such as primary or destructive.
- `label` (mixed|null, default `null`) — Accessible label for the grid; defaults to 'Dot matrix display'.

## Use when

- Use to summarize, sequence, or present data so users can scan it quickly.
- Displaying a decorative or ambient LED-style animation, such as a loader, equalizer, or scanning effect, made of individually lit dots.
- Framing the dot grid into a shape, such as a circle or hex, or freezing a custom glyph with a static pattern.

## Avoid when

- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Displaying real data or a chart; use a proper chart component so the values stay readable and accurate.

## Anti-patterns

- Adding display ornament without informational value

## Rules

- Use the `<brok:dot-matrix>` tag (or `<x-ui.dot-matrix>`) 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/dot-matrix
- Registry JSON (files, props, contract): https://brokui.dev/r/open/dot-matrix.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

presets.blade.php Blade
@php($looks = [
    ['orbit', 'Orbit', 'primary'],
    ['spiral', 'Spiral', 'info'],
    ['helix', 'Helix', 'success'],
    ['snake', 'Snake', 'warning'],
    ['rain', 'Rain', 'primary'],
    ['comet', 'Comet', 'accent'],
    ['scan', 'Scan', 'info'],
    ['twinkle', 'Twinkle', 'foreground'],
])
<div class="grid grid-cols-2 gap-8 sm:grid-cols-4">
    @foreach ($looks as [$preset, $label, $color])
        <div class="flex flex-col items-center gap-2 text-center">
            <x-ui.dot-matrix :preset="$preset" :rows="9" :cols="9" :color="$color" />
            <p class="text-xs text-muted-foreground">{{ __($label) }}</p>
        </div>
    @endforeach
</div>
shapes.blade.php Blade
<div class="flex flex-wrap items-end gap-10">
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="radar" shape="circular" :rows="13" :cols="13" />
        <p class="text-xs text-muted-foreground">{{ __('Circular') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="ripple" shape="triangle" :rows="11" :cols="13" color="success" />
        <p class="text-xs text-muted-foreground">{{ __('Triangle') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="pinwheel" shape="hex" :rows="13" :cols="15" color="info" />
        <p class="text-xs text-muted-foreground">{{ __('Hex') }}</p>
    </div>
    <div class="space-y-2 text-center">
        <x-ui.dot-matrix preset="pulse" shape="square" :rows="11" :cols="11" color="warning" />
        <p class="text-xs text-muted-foreground">{{ __('Square') }}</p>
    </div>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
rows int 7 Number of rows in the dot grid, clamped between 1 and 32.
cols int 15 Number of columns in the dot grid, clamped between 1 and 64.
preset string wave Built-in animation generated client-side, such as wave, pulse, vu, or loader.
shape string square Mask applied to the grid: square, circular, triangle, or hex.
fps int 12 Animation frame rate, clamped between 1 and 60.
size int 8 Pixel size of each dot.
gap int 4 Pixel gap between dots.
autoplay bool true Whether the animation starts automatically.
loop bool true Whether the animation repeats after finishing.
pattern mixed | null null Static 2D array of 0..1 brightness values that freezes a custom glyph instead of animating a preset.
color string primary Semantic token driving the lit dot color and glow, such as primary or destructive.
label mixed | null null Accessible label for the grid; defaults to 'Dot matrix display'.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

dot-matrix dot-matrix-pixel

Behavior

  • Generates its animation client-side from a built-in preset, such as wave, pulse, vu, or loader, at the given frame rate, or renders a fixed pattern when one is passed.
  • shape masks the rows x cols grid so the same animation reads as a circle, triangle, or hex without changing the preset.
  • Reduced motion shows a single static frame instead of animating.
  • 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

Scannable data display

Present data for rapid scanning.

Use when

  • Use to summarize, sequence, or present data so users can scan it quickly.
  • Displaying a decorative or ambient LED-style animation, such as a loader, equalizer, or scanning effect, made of individually lit dots.
  • Framing the dot grid into a shape, such as a circle or hex, or freezing a custom glyph with a static pattern.

Avoid when

  • Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
  • Displaying real data or a chart; use a proper chart component so the values stay readable and accurate.

Use instead

  • Table for exact comparison
  • Plain text for a single value

Anti-patterns

  • Adding display ornament without informational value
Anatomy
root dot-matrix-pixel
Theming hooks
dot-matrix dot-matrix-pixel

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root carries role=img and an aria-label, defaulting to 'Dot matrix display', since the grid is decorative pixels with no textual content of its own.
  • 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:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="dot-matrix-{{ $record->id }}">
    <div class="flex flex-wrap items-end gap-10">
        <div class="space-y-2 text-center">
            <x-ui.dot-matrix preset="wave" :rows="7" :cols="15" />
            <p class="text-xs text-muted-foreground">{{ __('Wave') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.dot-matrix preset="vu" :rows="7" :cols="9" />
            <p class="text-xs text-muted-foreground">{{ __('VU meter') }}</p>
        </div>
        <div class="space-y-2 text-center">
            <x-ui.dot-matrix preset="loader" :rows="7" :cols="7" color="success" />
            <p class="text-xs text-muted-foreground">{{ __('Loader') }}</p>
        </div>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/dot-matrix.blade.php Blade
@props([
    'rows' => 7,
    'cols' => 15,
    'preset' => 'wave',
    'shape' => 'square',
    'fps' => 12,
    'size' => 8,
    'gap' => 4,
    'autoplay' => true,
    'loop' => true,
    'pattern' => null,
    'color' => 'primary',
    'label' => null,
])

@php
    // An animated LED dot-matrix display: a grid of round "pixels" that light up
    // frame by frame. Built-in animations (wave / pulse / vu / loader / bars /
    // orbit / spiral / radar / ripple / helix / snake / rain / comet / scan /
    // pinwheel / twinkle / heart) are generated client-side from rows×cols; pass a
    // static `pattern` (2D array of 0..1 brightnesses) to freeze a custom glyph.
    //
    // The `shape` (square / circular / triangle / hex) masks the grid so the same
    // animation can read as a circle, a pyramid or a honeycomb — off-shape pixels
    // are simply not lit.
    //
    // Colour is a single semantic token: every pixel is `bg-<color>` and lights
    // by OPACITY (active pixels glow via a currentColor box-shadow), so it themes
    // for free and ships no raw colour. Dots are sized via inline numeric styles
    // (never spliced into utility classes). Reduced motion shows a static frame.
    $colorClasses = [
        'primary' => ['dot' => 'bg-primary', 'text' => 'text-primary'],
        'secondary' => ['dot' => 'bg-secondary-foreground', 'text' => 'text-secondary-foreground'],
        'accent' => ['dot' => 'bg-accent-foreground', 'text' => 'text-accent-foreground'],
        'destructive' => ['dot' => 'bg-destructive', 'text' => 'text-destructive'],
        'success' => ['dot' => 'bg-success', 'text' => 'text-success'],
        'warning' => ['dot' => 'bg-warning', 'text' => 'text-warning'],
        'info' => ['dot' => 'bg-info', 'text' => 'text-info'],
        'foreground' => ['dot' => 'bg-foreground', 'text' => 'text-foreground'],
    ];
    $picked = $colorClasses[$color] ?? $colorClasses['primary'];

    $presets = [
        'wave', 'pulse', 'vu', 'loader', 'bars',
        'orbit', 'spiral', 'radar', 'ripple', 'helix', 'snake',
        'rain', 'comet', 'scan', 'pinwheel', 'twinkle', 'heart',
    ];
    $shapes = ['square', 'circular', 'triangle', 'hex'];
    $hasPattern = is_array($pattern) && count($pattern) > 0;
    $config = [
        'rows' => max(1, min(32, (int) $rows)),
        'cols' => max(1, min(64, (int) $cols)),
        'preset' => in_array($preset, $presets, true) ? $preset : 'wave',
        'shape' => in_array($shape, $shapes, true) ? $shape : 'square',
        'fps' => max(1, min(60, (int) $fps)),
        'size' => max(2, (float) $size),
        'gap' => max(0, (float) $gap),
        'autoplay' => (bool) $autoplay,
        'loop' => (bool) $loop,
        'pattern' => $hasPattern ? $pattern : null,
    ];
    $ariaLabel = $label ?? __('Dot matrix display');
@endphp

<div
    data-slot="dot-matrix"
    x-data="uiDotMatrix(@js($config))"
    role="img"
    aria-label="{{ $ariaLabel }}"
    {{ $attributes->merge(['class' => $picked['text'].' inline-grid w-max']) }}
    :style="`grid-template-columns: repeat(${cols}, ${size}px); gap: ${gap}px`"
>
    <template x-for="(cell, idx) in cells" :key="idx">
        <span
            data-slot="dot-matrix-pixel"
            :data-on="masks[idx] !== 0 && cell > 0.5"
            class="{{ $picked['dot'] }} rounded-full motion-safe:transition-[opacity,box-shadow] motion-safe:duration-200"
            :class="masks[idx] !== 0 && cell > 0.5 ? 'shadow-[0_0_6px_currentColor]' : ''"
            :style="`width: ${size}px; height: ${size}px; opacity: ${masks[idx] === 0 ? 0 : (cell > 0.05 ? Math.max(0.12, cell) : 0.12)}`"
        ></span>
    </template>
</div>
resources/js/ui/dot-matrix.js JS
/**
 * Dot Matrix behaviour — an animated LED-style pixel display.
 *
 * State is a flat `cells` array (rows*cols) of brightnesses in [0, 1]; the Blade
 * binds each pixel's opacity/glow from it. A parallel flat `masks` array marks
 * which cells belong to the panel SHAPE (square / circular / triangle / hex) —
 * off-shape pixels are never drawn, so a square grid can read as a circle, a
 * pyramid or a honeycomb. Built-in presets are generated as a sequence of frames
 * from rows×cols and advanced by a frame-rate-limited rAF (an accumulator keeps
 * timing stable regardless of display refresh):
 *   • wave    — a travelling sine crest across the columns
 *   • pulse   — concentric rings breathing out from the centre
 *   • vu      — per-column volume bars, smoothed (synthetic levels, live)
 *   • loader  — a dot orbiting the centre
 *   • bars    — a staggered equaliser
 *   • orbit   — twin heads chasing the perimeter ring with tails
 *   • spiral  — a comet tail winding in toward the centre
 *   • radar   — a rotating sweep beam with a trailing wake
 *   • ripple  — a single wavefront expanding from the centre
 *   • helix   — two mirrored DNA strands woven across the columns
 *   • snake   — a serpentine head + tail threading every cell
 *   • rain    — Matrix-style columns falling at staggered speeds
 *   • comet   — a bright diagonal band sweeping NW→SE
 *   • scan    — a column beam sweeping left→right with a trail
 *   • pinwheel— four rotating blades around the centre
 *   • twinkle — scattered sparkles fading in and out (live)
 *   • heart   — a double-beat wave bursting from the centre
 * A static `pattern` (2D array) bypasses animation and is shown as-is.
 *
 * Reduced motion: the first frame is rendered once and the loop never starts.
 * destroy() cancels the rAF. Self-registers on `alpine:init`. Plain JS.
 */
document.addEventListener('alpine:init', () => {
    const clamp01 = (v) => Math.max(0, Math.min(1, v));
    const TAU = Math.PI * 2;
    // Wrap an angle delta to [-π, π].
    const wrapAngle = (d) => Math.atan2(Math.sin(d), Math.cos(d));

    // Presets regenerated live every tick (not stepped through a frame buffer).
    const LIVE = ['vu', 'twinkle'];

    window.Alpine.data('uiDotMatrix', (config = {}) => ({
        rows: Math.max(1, Math.min(32, config.rows ?? 7)),
        cols: Math.max(1, Math.min(64, config.cols ?? 15)),
        preset: config.preset || 'wave',
        shape: config.shape || 'square',
        fps: Math.max(1, Math.min(60, config.fps ?? 12)),
        size: Math.max(2, config.size ?? 8),
        gap: Math.max(0, config.gap ?? 4),
        autoplay: config.autoplay !== false,
        loops: config.loop !== false,
        pattern: config.pattern || null,

        cells: [],
        masks: [],
        _frames: [],
        _frameIndex: 0,
        _last: 0,
        _acc: 0,
        _vu: [],
        _tw: [],
        _raf: null,
        _reduced: false,
        _viewObserver: null,
        _offscreen: false,

        init() {
            this.cells = new Array(this.rows * this.cols).fill(0);
            this.masks = this.buildMask();
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            if (this.pattern) {
                this.flatten(this.fitFrame(this.pattern));
                return; // static custom glyph — no animation
            }

            this._frames = this.buildFrames();
            this.flatten(this._frames[0] || this.empty());

            if (this._reduced || !this.autoplay) return;
            this._raf = requestAnimationFrame((t) => this.tick(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.tick(t));
                    }
                });
                this._viewObserver.observe(this.$root);
            }
        },

        empty() {
            return Array.from({ length: this.rows }, () => new Array(this.cols).fill(0));
        },

        // Flat mask: 1 = cell is part of the panel shape, 0 = blank (never drawn).
        buildMask() {
            const m = new Array(this.rows * this.cols).fill(1);
            if (this.shape === 'square') return m;
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            for (let r = 0; r < this.rows; r++) {
                for (let c = 0; c < this.cols; c++) {
                    let inside = true;
                    if (this.shape === 'circular') {
                        const nx = (c - cx) / (cx + 0.5);
                        const ny = (r - cy) / (cy + 0.5);
                        inside = nx * nx + ny * ny <= 1;
                    } else if (this.shape === 'triangle') {
                        // Apex at the top: half-width grows linearly toward the base.
                        const ny = this.rows > 1 ? r / (this.rows - 1) : 1;
                        inside = Math.abs(c - cx) <= ny * cx + 0.5;
                    } else if (this.shape === 'hex') {
                        // Flat-top hexagon: full width in the middle band, chamfered ends.
                        const ny = this.rows > 1 ? r / (this.rows - 1) : 0.5;
                        let halfFrac = 1;
                        if (ny < 0.25) halfFrac = 0.5 + 2 * ny;
                        else if (ny > 0.75) halfFrac = 0.5 + 2 * (1 - ny);
                        inside = Math.abs(c - cx) <= halfFrac * cx + 0.5;
                    }
                    m[r * this.cols + c] = inside ? 1 : 0;
                }
            }
            return m;
        },

        // Copy an arbitrary frame into a rows×cols frame (pad/truncate).
        fitFrame(frame) {
            const out = this.empty();
            for (let r = 0; r < this.rows; r++) {
                for (let c = 0; c < this.cols; c++) {
                    out[r][c] = clamp01((frame[r] && frame[r][c]) || 0);
                }
            }
            return out;
        },

        flatten(frame) {
            const flat = new Array(this.rows * this.cols);
            for (let r = 0; r < this.rows; r++) {
                for (let c = 0; c < this.cols; c++) {
                    flat[r * this.cols + c] = frame[r][c];
                }
            }
            this.cells = flat;
        },

        // Distribute a fractional row position into a column (floor + ceil blend).
        plotCol(frame, y, c, b = 1) {
            if (c < 0 || c >= this.cols) return;
            const row = Math.floor(y);
            const frac = y - row;
            if (row >= 0 && row < this.rows) frame[row][c] = Math.max(frame[row][c], b * (1 - frac));
            if (row + 1 >= 0 && row + 1 < this.rows) frame[row + 1][c] = Math.max(frame[row + 1][c], b * frac);
        },

        tick(ts) {
            if (this._last === 0) this._last = ts;
            this._acc += ts - this._last;
            this._last = ts;
            const interval = 1000 / this.fps;

            if (this._acc >= interval) {
                this._acc -= interval;
                // Live presets are regenerated each step; others step through frames.
                if (LIVE.includes(this.preset)) {
                    this.flatten(this.liveFrame());
                } else {
                    this._frameIndex += 1;
                    if (this._frameIndex >= this._frames.length) {
                        this._frameIndex = this.loops ? 0 : this._frames.length - 1;
                    }
                    this.flatten(this._frames[this._frameIndex]);
                }
            }
            this._raf = requestAnimationFrame((t) => this.tick(t));
        },

        // ---- preset frame builders ----------------------------------------

        buildFrames() {
            switch (this.preset) {
                case 'pulse':
                    return this.pulseFrames();
                case 'loader':
                    return this.loaderFrames();
                case 'bars':
                    return this.barsFrames();
                case 'orbit':
                    return this.orbitFrames();
                case 'spiral':
                    return this.spiralFrames();
                case 'radar':
                    return this.radarFrames();
                case 'ripple':
                    return this.rippleFrames();
                case 'helix':
                    return this.helixFrames();
                case 'snake':
                    return this.snakeFrames();
                case 'rain':
                    return this.rainFrames();
                case 'comet':
                    return this.cometFrames();
                case 'scan':
                    return this.scanFrames();
                case 'pinwheel':
                    return this.pinwheelFrames();
                case 'heart':
                    return this.heartFrames();
                case 'vu':
                    this._vu = new Array(this.cols).fill(0);
                    return [this.empty()];
                case 'twinkle':
                    this._tw = new Array(this.rows * this.cols).fill(0);
                    return [this.empty()];
                case 'wave':
                default:
                    return this.waveFrames();
            }
        },

        waveFrames() {
            const steps = 24;
            const frames = [];
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const phase = (f / steps) * TAU;
                for (let c = 0; c < this.cols; c++) {
                    const colPhase = (c / this.cols) * TAU;
                    const h = (Math.sin(phase + colPhase) * 0.5 + 0.5) * (this.rows - 1);
                    const row = Math.floor(h);
                    const frac = h - row;
                    if (row >= 0 && row < this.rows) frame[row][c] = 1;
                    if (row + 1 < this.rows) frame[row + 1][c] = frac;
                    if (row - 1 >= 0) frame[row - 1][c] = 1 - frac;
                }
                frames.push(frame);
            }
            return frames;
        },

        pulseFrames() {
            const steps = 20;
            const frames = [];
            const cy = (this.rows - 1) / 2;
            const cx = (this.cols - 1) / 2;
            const maxR = Math.sqrt(cx * cx + cy * cy) || 1;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const ring = (f / steps) * maxR;
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        const d = Math.sqrt((c - cx) ** 2 + (r - cy) ** 2);
                        frame[r][c] = clamp01(1 - Math.abs(d - ring) * 0.9);
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        loaderFrames() {
            const steps = 16;
            const frames = [];
            const cy = (this.rows - 1) / 2;
            const cx = (this.cols - 1) / 2;
            const radius = Math.max(1, Math.min(cx, cy));
            const tail = 6;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                for (let t = 0; t < tail; t++) {
                    const angle = ((f - t) / steps) * TAU;
                    const x = Math.round(cx + Math.cos(angle) * radius);
                    const y = Math.round(cy + Math.sin(angle) * radius);
                    if (y >= 0 && y < this.rows && x >= 0 && x < this.cols) {
                        frame[y][x] = Math.max(frame[y][x], 1 - t / tail);
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        barsFrames() {
            const steps = 30;
            const frames = [];
            const seeds = Array.from({ length: this.cols }, (_, c) => c * 0.7);
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const time = (f / steps) * TAU;
                for (let c = 0; c < this.cols; c++) {
                    const level = (Math.sin(time * 2 + seeds[c]) * 0.5 + 0.5) * this.rows;
                    for (let r = 0; r < this.rows; r++) {
                        const fromBottom = this.rows - 1 - r;
                        frame[r][c] = fromBottom < level ? 1 : 0;
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // Twin heads chasing the perimeter ring, each with a fading tail.
        orbitFrames() {
            const steps = 24;
            const frames = [];
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            const radius = Math.max(1, Math.min(cx, cy));
            const tail = 5;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                for (let h = 0; h < 2; h++) {
                    for (let t = 0; t < tail; t++) {
                        const angle = ((f - t) / steps) * TAU + h * Math.PI;
                        const x = Math.round(cx + Math.cos(angle) * radius);
                        const y = Math.round(cy + Math.sin(angle) * radius);
                        if (y >= 0 && y < this.rows && x >= 0 && x < this.cols) {
                            frame[y][x] = Math.max(frame[y][x], 1 - t / tail);
                        }
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // A comet tail winding in toward the centre along an Archimedean spiral.
        spiralFrames() {
            const steps = 28;
            const frames = [];
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            const maxR = Math.max(1, Math.min(cx, cy));
            const tail = 8;
            const turns = 2.2;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                for (let t = 0; t < tail; t++) {
                    const p = (((f - t) / steps) % 1 + 1) % 1;
                    const angle = p * TAU * turns;
                    const radius = maxR * (1 - p);
                    const x = Math.round(cx + Math.cos(angle) * radius);
                    const y = Math.round(cy + Math.sin(angle) * radius);
                    if (y >= 0 && y < this.rows && x >= 0 && x < this.cols) {
                        frame[y][x] = Math.max(frame[y][x], 1 - t / tail);
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // A rotating sweep beam with a trailing angular wake.
        radarFrames() {
            const steps = 28;
            const frames = [];
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            const spread = Math.PI * 0.6;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const beam = (f / steps) * TAU;
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        if (Math.abs(c - cx) < 0.6 && Math.abs(r - cy) < 0.6) {
                            frame[r][c] = 1; // bright hub
                            continue;
                        }
                        const ang = Math.atan2(r - cy, c - cx);
                        const d = ((beam - ang) % TAU + TAU) % TAU; // distance behind beam
                        frame[r][c] = d < spread ? clamp01(1 - d / spread) : 0;
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // A single wavefront expanding from the centre and fading as it grows.
        rippleFrames() {
            const steps = 22;
            const frames = [];
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            const maxR = Math.sqrt(cx * cx + cy * cy) || 1;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const p = f / steps;
                const ring = p * maxR;
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        const d = Math.sqrt((c - cx) ** 2 + (r - cy) ** 2);
                        frame[r][c] = clamp01((1 - Math.abs(d - ring) * 0.9) * (1 - p * 0.7));
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // Two mirrored DNA strands woven across the columns, brightening where they cross.
        helixFrames() {
            const steps = 24;
            const frames = [];
            const cy = (this.rows - 1) / 2;
            const amp = ((this.rows - 1) / 2) * 0.85;
            const turns = 2;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const phase = (f / steps) * TAU;
                for (let c = 0; c < this.cols; c++) {
                    const a = (c / this.cols) * TAU * turns + phase;
                    const offset = Math.sin(a) * amp;
                    this.plotCol(frame, cy + offset, c, 1);
                    this.plotCol(frame, cy - offset, c, 1);
                    // Bridge rung where the strands nearly meet.
                    if (Math.abs(offset) < amp * 0.25) this.plotCol(frame, cy, c, 0.5);
                }
                frames.push(frame);
            }
            return frames;
        },

        // A serpentine head + tail threading every cell row by row.
        snakeFrames() {
            const path = [];
            for (let r = 0; r < this.rows; r++) {
                const order = [];
                for (let c = 0; c < this.cols; c++) order.push(c);
                if (r % 2 === 1) order.reverse();
                for (const c of order) path.push([r, c]);
            }
            const steps = path.length;
            const tail = Math.max(4, Math.round(steps * 0.22));
            const frames = [];
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                for (let t = 0; t < tail; t++) {
                    const [r, c] = path[(f - t + steps) % steps];
                    frame[r][c] = Math.max(frame[r][c], 1 - t / tail);
                }
                frames.push(frame);
            }
            return frames;
        },

        // Matrix-style columns falling at staggered speeds.
        rainFrames() {
            const tail = 4;
            const period = this.rows + tail + 2;
            const steps = period;
            const seed = Array.from({ length: this.cols }, (_, c) => (c * 5) % period);
            const frames = [];
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                for (let c = 0; c < this.cols; c++) {
                    const head = (f + seed[c]) % period;
                    for (let t = 0; t < tail; t++) {
                        const row = head - t;
                        if (row >= 0 && row < this.rows) {
                            frame[row][c] = Math.max(frame[row][c], 1 - t / tail);
                        }
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // A bright diagonal band sweeping NW→SE, wrapping seamlessly.
        cometFrames() {
            const steps = 24;
            const frames = [];
            const band = 0.28;
            const dcMax = Math.max(1, this.cols - 1) + Math.max(1, this.rows - 1);
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const front = f / steps;
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        const dc = (c + r) / dcMax;
                        const d = ((front - dc) % 1 + 1) % 1;
                        frame[r][c] = d < band ? clamp01(1 - d / band) : 0;
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // A full-height column beam sweeping left→right with a short trail.
        scanFrames() {
            const steps = this.cols;
            const frames = [];
            const trail = 3;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                for (let t = 0; t < trail; t++) {
                    const col = ((f - t) % this.cols + this.cols) % this.cols;
                    const b = 1 - t / trail;
                    for (let r = 0; r < this.rows; r++) frame[r][col] = Math.max(frame[r][col], b);
                }
                frames.push(frame);
            }
            return frames;
        },

        // Four rotating blades around the centre.
        pinwheelFrames() {
            const steps = 24;
            const frames = [];
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            const blades = 4;
            const spread = 0.55;
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const rot = (f / steps) * TAU;
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        const ang = Math.atan2(r - cy, c - cx);
                        let best = 0;
                        for (let k = 0; k < blades; k++) {
                            const d = Math.abs(wrapAngle(ang - (rot + (k * TAU) / blades)));
                            best = Math.max(best, 1 - d / spread);
                        }
                        frame[r][c] = clamp01(best);
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // A double-beat wavefront bursting from the centre (lub-dub).
        heartFrames() {
            const steps = 24;
            const frames = [];
            const cx = (this.cols - 1) / 2;
            const cy = (this.rows - 1) / 2;
            const maxR = Math.sqrt(cx * cx + cy * cy) || 1;
            const bump = (t, c) => Math.exp(-(((t - c) * 7) ** 2));
            for (let f = 0; f < steps; f++) {
                const frame = this.empty();
                const t = f / steps;
                const env = clamp01(bump(t, 0.12) + bump(t, 0.32) * 0.85);
                const ring = t * maxR;
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        const d = Math.sqrt((c - cx) ** 2 + (r - cy) ** 2);
                        frame[r][c] = clamp01(env * (1 - Math.abs(d - ring) * 0.7));
                    }
                }
                frames.push(frame);
            }
            return frames;
        },

        // ---- live presets --------------------------------------------------

        liveFrame() {
            return this.preset === 'twinkle' ? this.twinkleFrame() : this.vuFrame();
        },

        // Live VU: smooth per-column random levels toward synthetic targets.
        vuFrame() {
            const frame = this.empty();
            for (let c = 0; c < this.cols; c++) {
                const target = Math.random();
                this._vu[c] = this._vu[c] * 0.7 + target * 0.3;
                const level = this._vu[c] * this.rows;
                for (let r = 0; r < this.rows; r++) {
                    const fromBottom = this.rows - 1 - r;
                    if (fromBottom < level) {
                        // Brighter at the base, dimmer toward the peak.
                        frame[r][c] = r > this.rows * 0.6 ? 1 : r > this.rows * 0.3 ? 0.8 : 0.6;
                    }
                }
            }
            return frame;
        },

        // Live twinkle: each cell decays, with occasional random sparkles igniting.
        twinkleFrame() {
            const frame = this.empty();
            const n = this.rows * this.cols;
            // Ignite a few random cells, then decay the whole buffer.
            const ignite = Math.max(1, Math.round(n * 0.06));
            for (let i = 0; i < ignite; i++) {
                this._tw[Math.floor(Math.random() * n)] = 1;
            }
            for (let r = 0; r < this.rows; r++) {
                for (let c = 0; c < this.cols; c++) {
                    const idx = r * this.cols + c;
                    this._tw[idx] *= 0.72;
                    frame[r][c] = this._tw[idx];
                }
            }
            return frame;
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            this._raf = null;
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.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