Skip to content
Brok UI

Loading…

No results

Matrix Rain

Open source

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).

Version
v1.0.5
Stability
stable
License
MIT
Related
Particles
Starfield
Dither Gradient

Preview

previews.components.matrix-rain.default.blade.php 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>

Installation

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

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

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

matrix-rain.md
# 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

manifest knowledge + registry-derived coverage

Props

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

matrix-rain

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

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 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
root
Theming hooks
matrix-rain

Accessibility

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

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