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.
Preview
Wave
VU meter
Loader
<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
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:
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.
-
resources/views/components/ui/dot-matrix.blade.php -
resources/js/ui/dot-matrix.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: 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
@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>
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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 a stable wire:key when Livewire can reorder this interactive component.
<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.
@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>
/**
* 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