effects
Matrix Rain Loader
A retro 'digital rain' loading indicator: a grid of monospace glyphs (binary by default) tumble down a 3D-perspective column, each one falling on a staggered cadence with a quick mid-cycle flicker, over a softly pulsing radial field. Built from three co-located keyframes (matrix-fall does a translateY + rotateX(90deg→0→-90deg) tumble, matrix-flicker dips opacity, matrix-pulse breathes the glow). Glyph and glow color are token-driven (var(--primary), overridable via the color prop) so it themes automatically; per-glyph stagger is set once via a --delay custom property rather than per-frame React. Exposes role="status" with an aria-label for assistive tech.
Ported from PriyanshuGupta28 (MIT)
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/matrix-rain-loaderUsage
<MatrixRainLoader />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| pattern | string | 01011001 | The glyphs to tumble through the grid, one cell per character. Defaults to an 8-bit binary string. Any string works ("ABCDEF", katakana, etc.). |
| columns | number | 3 | Number of grid columns the glyphs flow down (default 3). |
| scale | number | 1 | Overall scale multiplier for the loader box (1 = the original 120×160 footprint). |
| speed | number | 2 | Seconds for one full fall cycle of a single glyph (default 2). The flicker and pulse cadences derive from this so the whole loader stays in phase. |
| color | string | — | Optional CSS color for the glyphs + glow. Defaults to a token-derived `var(--primary)` so it themes automatically. Accepts any valid CSS color. |
| label | string | Loading | Accessible status label announced to assistive tech (default "Loading"). |
| className | string | Additional class names merged onto the root element. | |
| as | React.ElementType | — | Polymorphic root element (default "div"). |
Source
"use client";
import { createElement, useEffect, useMemo, useState } from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./matrix-rain-loader.module.css";
/**
* Default binary pattern (mirrors the original "0 1 0 1 1 0 0 1" loader).
* Each glyph is a separate falling cell.
*/
const DEFAULT_PATTERN = "01011001";
export type MatrixRainLoaderProps = {
/**
* The glyphs to tumble through the grid, one cell per character.
* Defaults to an 8-bit binary string. Any string works ("ABCDEF", katakana, etc.).
*/
pattern?: string;
/** Number of grid columns the glyphs flow down (default 3). */
columns?: number;
/** Overall scale multiplier for the loader box (1 = the original 120×160 footprint). */
scale?: number;
/**
* Seconds for one full fall cycle of a single glyph (default 2). The flicker and
* pulse cadences derive from this so the whole loader stays in phase.
*/
speed?: number;
/**
* Optional CSS color for the glyphs + glow. Defaults to a token-derived
* `var(--primary)` so it themes automatically. Accepts any valid CSS color.
*/
color?: string;
/** Accessible status label announced to assistive tech (default "Loading"). */
label?: string;
/** Additional class names merged onto the root element. */
className?: string;
/** Polymorphic root element (default "div"). */
as?: React.ElementType;
} & Omit<React.HTMLAttributes<HTMLElement>, "color">;
export function MatrixRainLoader({
pattern = DEFAULT_PATTERN,
columns = 3,
scale = 1,
speed = 2,
color,
label = "Loading",
className = "",
as,
style: consumerStyle,
...props
}: MatrixRainLoaderProps) {
const Root = (as ?? "div") as React.ElementType;
// Mount-gate + null-safe reduced-motion check (contract pattern).
// useReducedMotion() is null on SSR and a boolean on first client render, so we
// keep the server/first-client render byte-identical (static base) and only flip
// on the falling animation after mount for non-reduced users. The DOM structure is
// identical in both branches — only animation *values* change — so there is no #418.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
const glyphs = useMemo(() => Array.from(pattern), [pattern]);
const count = glyphs.length;
// Per-glyph stagger spread evenly across one cycle, matching the original's
// 0.1s..1.5s cascade. Computed once per (count, speed) instead of per frame.
const delayStep = count > 1 ? speed / count : 0;
// CSS variables drive every timing + color so nothing is hardcoded in JS motion.
// Consumer style is merged first; the component's required vars apply last so they
// can't be dropped while consumer overrides are still honored.
const rootStyle: React.CSSProperties & Record<string, string> = {
...consumerStyle,
"--columns": String(Math.max(1, columns)),
"--scale": String(scale),
"--fall-duration": `${speed}s`,
"--flicker-duration": `${speed * 0.25}s`,
"--pulse-duration": `${speed}s`,
} as React.CSSProperties & Record<string, string>;
if (color) {
rootStyle["--matrix-color"] = color;
}
const rootClassName = [styles.root, animate && styles.animate, className]
.filter(Boolean)
.join(" ");
return createElement(
Root,
{
...props,
className: rootClassName,
style: rootStyle,
role: "status",
"aria-label": label,
"aria-live": "polite",
},
glyphs.map((g, i) =>
createElement(
"span",
{
key: i,
className: styles.digit,
"aria-hidden": "true",
style: { "--delay": `${(i * delayStep).toFixed(3)}s` } as React.CSSProperties,
},
g,
),
),
createElement("span", { key: "glow", "aria-hidden": "true", className: styles.glow }),
);
}
Dependencies
- motion
- @bottega/tokens