Matrix Rain
A decorative digital-rain canvas: columns of glyphs fall down the box with a bright leading character and a fading trail, in a semantic token color, looping. DPR aware and reduced-motion safe (static frame).
Preview
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.matrix-rain />
</div>
Installation
php artisan ui:add matrix-rain
Note
This component ships an Alpine behavior module at
resources/js/ui/matrix-rain.js. Import it once from your bundle so it registers on alpine:init:
import './matrix-rain.js';
Registry contract
php artisan ui:add matrix-rain
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/matrix-rain.blade.php -
resources/js/ui/matrix-rain.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: Matrix Rain (`matrix-rain`)
A decorative digital-rain canvas: columns of glyphs fall down the box with a bright leading character and a fading trail, in a semantic token color, looping. DPR aware and reduced-motion safe (static frame).
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add matrix-rain
```
## Usage
```blade
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.matrix-rain />
</div>
```
## Props
- `fontSize` (int, default `14`) — Glyph size in pixels, which also sets each column's width.
- `speed` (int, default `1`) — Rows a column's head advances per frame.
- `characters` (mixed|null, default `null`) — Custom glyph set to draw from; defaults to the classic katakana/latin/digit set.
- `color` (string, default `primary`) — Semantic colour token for the glyphs (primary, secondary, accent, muted, destructive, success, warning, info, foreground).
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a hacker/terminal-styled falling-glyph background behind a hero or section.
- Wanting the classic digital-rain effect themed through a semantic colour token instead of hard-coded green.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a calmer ambient field; use particles or starfield instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:matrix-rain>` tag (or `<x-ui.matrix-rain>`) 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/matrix-rain
- Registry JSON (files, props, contract): https://brokui.dev/r/open/matrix-rain.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| fontSize | int | 14 | Glyph size in pixels, which also sets each column's width. |
| speed | int | 1 | Rows a column's head advances per frame. |
| characters | mixed | null | null | Custom glyph set to draw from; defaults to the classic katakana/latin/digit set. |
| color | string | primary | Semantic colour token for the glyphs (primary, secondary, accent, muted, destructive, success, warning, info, foreground). |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Divides the canvas into glyph-width columns, each tracking a falling head position; every frame it paints a translucent overlay derived from the background token to fade previous glyphs into a trail, draws a random glyph at each column's head, and recycles heads that pass the bottom back to the top at a random offset.
- Glyph colour is read from the computed color token and the trail-fade colour from the nearest ancestor's background, so both re-theme automatically; a MutationObserver re-resolves them on a light/dark class flip.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Support brand expression or a showcase moment.
Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a hacker/terminal-styled falling-glyph background behind a hero or section.
- Wanting the classic digital-rain effect themed through a semantic colour token instead of hard-coded green.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a calmer ambient field; use particles or starfield instead.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The host is aria-hidden.
- Reduced motion renders a single static field of glyphs at varying column heights instead of animating.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-card text-primary">
<x-ui.matrix-rain />
</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([
'fontSize' => 14,
'speed' => 1,
'characters' => null,
'color' => 'primary',
])
@php
// Map the semantic color name to a design-system text-* class. The glyph
// color is read at runtime from the canvas's computed `color`, so theming
// (or wrapping in a different text-* utility) changes the rain — nothing is
// hard-coded. The classic Matrix green is achievable purely by theming.
$colorClasses = [
'primary' => 'text-primary',
'secondary' => 'text-secondary',
'accent' => 'text-accent',
'muted' => 'text-muted-foreground',
'destructive' => 'text-destructive',
'success' => 'text-success',
'warning' => 'text-warning',
'info' => 'text-info',
'foreground' => 'text-foreground',
];
$colorClass = $colorClasses[$color] ?? 'text-primary';
$config = [
'fontSize' => max(6, (int) $fontSize),
// speed scales how far heads advance per frame (1 = one row per frame).
'speed' => max(0.05, (float) $speed),
'characters' => is_string($characters) && trim($characters) !== '' ? $characters : null,
];
@endphp
{{-- Decorative fill layer. The whole component is aria-hidden: it carries no
content, just an ambient effect, so assistive tech skips it. The canvas
inherits its glyph color from this element's computed `color`. --}}
<div
data-slot="matrix-rain"
x-data="uiMatrixRain(@js($config))"
x-ref="root"
aria-hidden="true"
{{ $attributes->merge(['class' => $colorClass . ' pointer-events-none absolute inset-0 block h-full w-full overflow-hidden']) }}
>
<canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
/**
* Matrix Rain behavior (the classic digital rain, faithful reimplementation).
*
* A single <canvas> fills the parent box. The width is divided into columns of
* `fontSize` px; each column tracks a `y` drop position (in rows). Every frame
* we:
* 1. paint a translucent overlay derived from the box BACKGROUND token over
* the whole canvas — this is what fades the previous glyphs into a trail
* instead of clearing them;
* 2. draw a random glyph at each column's head in the FOREGROUND token color,
* with the head glyph drawn slightly brighter than the trail;
* 3. advance each head by `speed` rows and, once it passes the bottom, reset
* it to the top at a random offset so columns stay desynchronised.
*
* Color is SEMANTIC: the glyph color is read from the canvas's computed `color`
* (a text-* token, default text-primary) and the fade overlay is derived from
* the computed `backgroundColor` of the box (or its nearest opaque ancestor),
* falling back to a low-alpha black. Nothing is hard-coded — classic green is
* achieved by theming/utilities, not by the component.
*
* Sizing: the backing store is scaled for devicePixelRatio and a ResizeObserver
* re-lays-out the columns when the box resizes. Reduced motion: a single STATIC
* frame of characters is drawn and no animation loop / rAF runs.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiMatrixRain', (config = {}) => ({
fontSize: Math.max(6, config.fontSize ?? 14),
speed: Math.max(0.05, config.speed ?? 1),
// Katakana + latin + digits — the canonical digital-rain glyph set.
characters:
config.characters ||
'アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌフムユュルグズブヅプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ',
_ctx: null,
_dpr: 1,
_cssW: 0,
_cssH: 0,
_columns: 0,
_drops: [],
_glyph: null,
_fade: 'transparent',
_raf: null,
_ro: null,
_mo: null,
_reduced: false,
_viewObserver: null,
_offscreen: false,
init() {
const canvas = this.$refs.canvas;
this._ctx = canvas.getContext('2d');
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.layout();
// Re-read the semantic token colours when the app flips the `.dark`
// class on <html>; cheap (one resolve) and keeps a live theme toggle
// on-token. Active in both motion modes so a static field re-themes.
this._mo = new MutationObserver(() => {
this.resolveColors();
if (this._reduced) this.drawStatic();
});
this._mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
if (this._reduced) {
// Static frame only: a single pass of random glyphs, no rAF.
this.drawStatic();
return;
}
// Re-layout on resize (debounced to a frame) so columns track size.
let pending = false;
this._ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
this.layout();
});
});
this._ro.observe(this.$refs.root);
this._raf = requestAnimationFrame(() => this.loop());
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame(() => this.loop());
}
});
this._viewObserver.observe(this.$refs.root);
}
},
// Resolve the glyph + fade colors from semantic tokens at runtime.
resolveColors() {
const root = this.$refs.root;
const c = getComputedStyle(root).color;
this._glyph = c && c !== '' ? c : getComputedStyle(document.body).color;
// Find the nearest ancestor with a non-transparent background to
// derive the trail-fade overlay; this keeps the rain readable on
// light or dark themes. Fall back to low-alpha black.
this._fade = this.resolveFade(root);
},
resolveFade(el) {
let node = el;
while (node && node instanceof Element) {
const bg = getComputedStyle(node).backgroundColor;
const parsed = this.parseRgb(bg);
if (parsed && parsed.a > 0) {
// Low alpha so each frame only partially erases — that
// partial erase is what produces the fading trail.
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, 0.08)`;
}
node = node.parentElement;
}
const tokenBackground = this.resolveTokenColor('--background', el);
const parsed = this.parseRgb(tokenBackground);
return parsed ? `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, 0.08)` : 'transparent';
},
resolveTokenColor(name, el) {
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;width:0;height:0;';
el.appendChild(probe);
probe.style.color = `hsl(var(${name}))`;
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved || '';
},
parseRgb(str) {
const m = String(str).match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(',').map((p) => parseFloat(p.trim()));
return {
r: parts[0] || 0,
g: parts[1] || 0,
b: parts[2] || 0,
a: parts.length > 3 ? parts[3] : 1,
};
},
// Size the backing store for DPR and (re)build the per-column drops.
layout() {
const canvas = this.$refs.canvas;
const root = this.$refs.root;
this._dpr = Math.max(1, window.devicePixelRatio || 1);
this.resolveColors();
this._cssW = Math.max(1, root.clientWidth || 1);
this._cssH = Math.max(1, root.clientHeight || 1);
canvas.width = Math.round(this._cssW * this._dpr);
canvas.height = Math.round(this._cssH * this._dpr);
// Work in CSS pixels; the transform scales to device pixels.
this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
const columns = Math.max(1, Math.ceil(this._cssW / this.fontSize));
// Preserve existing drop positions on resize where possible so the
// animation does not visibly jump; seed new columns at random rows.
const rows = this._cssH / this.fontSize;
const drops = new Array(columns);
for (let i = 0; i < columns; i++) {
drops[i] =
this._drops && this._drops[i] !== undefined
? this._drops[i]
: Math.random() * rows;
}
this._columns = columns;
this._drops = drops;
// Reset the canvas to the (opaque) background before animating so
// the first frames are not drawn over stale pixels.
const ctx = this._ctx;
ctx.clearRect(0, 0, this._cssW, this._cssH);
if (this._reduced) this.drawStatic();
},
randGlyph() {
const chars = this.characters;
return chars.charAt(Math.floor(Math.random() * chars.length));
},
// One animation step: fade overlay, draw heads, advance + recycle.
loop() {
const ctx = this._ctx;
const fs = this.fontSize;
// 1. Translucent overlay → fading trail.
ctx.fillStyle = this._fade;
ctx.fillRect(0, 0, this._cssW, this._cssH);
ctx.font = `${fs}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textBaseline = 'top';
const rows = this._cssH / fs;
for (let i = 0; i < this._columns; i++) {
const x = i * fs;
const y = this._drops[i] * fs;
// Head glyph slightly brighter than the trailing color.
ctx.globalAlpha = 1;
ctx.fillStyle = this._glyph;
ctx.fillText(this.randGlyph(), x, y);
// 3. Advance the head; recycle to the top once below the box.
if (this._drops[i] > rows && Math.random() > 0.975) {
this._drops[i] = 0;
} else {
this._drops[i] += this.speed;
}
}
ctx.globalAlpha = 1;
this._raf = requestAnimationFrame(() => this.loop());
},
// Reduced motion: a single static field of glyphs, no animation.
drawStatic() {
const ctx = this._ctx;
if (!ctx) return;
const fs = this.fontSize;
ctx.clearRect(0, 0, this._cssW, this._cssH);
ctx.font = `${fs}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textBaseline = 'top';
ctx.fillStyle = this._glyph;
const cols = this._columns;
const rows = Math.ceil(this._cssH / fs);
for (let i = 0; i < cols; i++) {
// Fill each column down to a random height so it reads as rain
// frozen mid-fall rather than a uniform grid.
const fill = Math.floor(Math.random() * rows);
for (let r = 0; r <= fill; r++) {
// Fade toward the top of each column for depth.
ctx.globalAlpha = Math.max(0.15, r / Math.max(1, fill));
ctx.fillText(this.randGlyph(), i * fs, r * fs);
}
}
ctx.globalAlpha = 1;
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
if (this._ro) {
this._ro.disconnect();
this._ro = null;
}
if (this._mo) {
this._mo.disconnect();
this._mo = null;
}
this._drops = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.5 - 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