bottega

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

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/matrix-rain-loader

Usage

Usagetsx
<MatrixRainLoader />

Props

PropTypeDefaultDescription
patternstring01011001The glyphs to tumble through the grid, one cell per character. Defaults to an 8-bit binary string. Any string works ("ABCDEF", katakana, etc.).
columnsnumber3Number of grid columns the glyphs flow down (default 3).
scalenumber1Overall scale multiplier for the loader box (1 = the original 120×160 footprint).
speednumber2Seconds 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.
colorstringOptional CSS color for the glyphs + glow. Defaults to a token-derived `var(--primary)` so it themes automatically. Accepts any valid CSS color.
labelstringLoadingAccessible status label announced to assistive tech (default "Loading").
classNamestringAdditional class names merged onto the root element.
asReact.ElementTypePolymorphic root element (default "div").

Source

matrix-rain-loader.tsxtsx
"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