Backgrounds

Matrix Rain

Falling glyph columns on one canvas — every column keeps its own speed, length and bright head, and glyphs swap in place so the stream reads as changing rather than scrolling.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Named glyph sets. The `glyphs` prop overrides whichever one is selected. */
export type MatrixRainCharset = "binary" | "hex" | "katakana"

export type MatrixRainTone =
  | "chart-1"
  | "chart-2"
  | "chart-3"
  | "chart-4"
  | "chart-5"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/matrix-rain.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MatrixRain" component — falling glyph
columns painted on one canvas, used as a terminal / security / launch backdrop.
Its only dependency is a cn() class merger (clsx + tailwind-merge).

Contract
- export function MatrixRain(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - charset?: "katakana" | "hex" | "binary" (default "katakana") — built-in
    alphabets: katakana plus digits, 0-9A-F, and 01.
  - glyphs?: string — custom alphabet; overrides charset when non-empty. Split
    it with Array.from (code points, so an astral glyph does not tear into
    surrogate halves) and truncate to 256 entries, because one grid cell holds
    a Uint8 index.
  - fontSize?: number (default 15) — CSS px, clamped 6..96. Column pitch and
    row height both derive from it.
  - speed?: number (default 1) — multiplier on the simulation. 0 freezes the
    field on its composed first frame and the rAF loop never starts at all.
  - density?: number (default 0.85) — share of columns carrying a live stream,
    clamped 0.05..1; 1 is continuous rain with no gaps.
  - trail?: number (default 18) — maximum trail length in rows, clamped 2..60
    and capped again by the per-frame glyph budget below.
  - swapRate?: number (default 3) — in-place glyph swaps per second per column,
    clamped 0..60. 0 keeps the streams falling with frozen glyphs.
  - tone?: "foreground" | "primary" | "muted" | "chart-1".."chart-5" (default
    "primary") and headTone?: same union (default "foreground") — which
    semantic token paints the tail and which paints the leading glyph.
  - opacity?: number (default 0.85) — peak alpha of the whole field.
  - seed?: number (default 11) — integer seed for the column map.
  - children render above the canvas; className merges onto the root.
- "use client": canvas, rAF, observers.
- Clamp every numeric prop before use and treat non-finite values as the
  default: fontSize 0 asks for infinite columns, density 0 pushes every stream
  infinitely far above the top edge, a negative speed runs the rain upward.

Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
  aria-hidden, pointer-events-none, absolute inset-0, size-full and font-mono —
  size-full matters, an absolutely positioned replaced element with inset-0
  alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
  children, so content always sits above the field and the canvas can never
  intercept a click. The component paints no background of its own: the surface
  belongs to the consumer.
- Grid: set the font, measure the widest glyph of the active alphabet with
  ctx.measureText, multiply by 1.1 for a gutter — that is the cell width, so an
  alphabet of full-width katakana and one of hex digits both get honest
  spacing. Column count = ceil(width / cellWidth) capped at 200, and then the
  drawing pitch is re-derived as width / count so the columns tile the
  container exactly instead of leaving a bald strip on the right when the cap
  bites. Row height is fontSize * 1.16; rows = ceil(height / rowHeight) + 1.
- Per-column state, one object per column and never one DOM node per glyph:
  head position in fractional rows, fall rate in rows/s, trail length, the last
  integer row the head occupied, a swap timer, a Uint8Array holding one glyph
  index per row cell, and the column's own 32-bit LCG state seeded from (seed,
  index). Math.random() is never called — not during render (purity/SSR) and
  not in the loop (the first frame must be reproducible).
- THE central idea: glyphs are anchored to fixed grid cells and only the
  BRIGHTNESS ENVELOPE travels down them. Drawing glyphs at the head's
  fractional offset would slide the whole column like a texture and instantly
  read as scrolling wallpaper. Instead cell positions are fixed, the alpha of a
  cell is (1 - (head - row) / length) raised to ~1.5, and the head cell is drawn
  in the head ink at full alpha. What moves is light across static type.
- Two sources write glyphs. (1) Every cell the head crosses gets a fresh one —
  a loop from lastRow + 1 up to floor(head), not a single write, because one
  long frame or a large speed crosses several cells at once and skipped cells
  leave stale glyphs inside the trail. (2) A per-column timer swaps one random
  glyph inside the lit trail roughly swapRate times a second. That second
  source is the entire difference between a stream that changes and a texture
  that scrolls.
- A swap never re-picks the glyph already in the cell: with charset "binary" a
  plain random draw is a visual no-op half the time. Use
  (current + 1 + floor(rand * (n - 1))) % n, and skip swapping altogether when
  the alphabet has fewer than two glyphs.
- density is spent as vertical gap: gap = rows * (1 / density - 1). A
  respawning column restarts at -rand * gap, so density 1 restarts it the
  instant its trail clears the bottom (unbroken rain) and density 0.5 parks it
  about one screen height above the top. On the FIRST seeding the head is
  instead spread uniformly over the whole cycle, -gap .. rows + length, so frame
  zero is already a settled field rather than an empty box that slowly fills.
- Cost, stated honestly: this is text rendering, the most expensive thing you
  can ask a 2D canvas to do per frame. The budget is a hard 2600 fillText calls
  per frame — effective trail = min(trail, floor(2600 / columns), rows) — so
  shrinking fontSize buys more columns instead of multiplying columns x trail
  into a frozen tab. Draw in two passes, all tails then all heads, so fillStyle
  is assigned twice per frame instead of twice per column; set ctx.font once per
  frame (it parses a CSS shorthand on every assignment) and never per glyph;
  pre-split the alphabet into an array of single-character strings so fillText
  is not handed a freshly minted one-char string every time; skip any cell below
  alpha 0.02. Nothing is allocated inside the loop — the Uint8Arrays are pooled
  and only reallocated when the row count actually changes.
- Ink: the canvas carries the tail token as an inline color and the head token
  as an inline custom property. The loop reads getComputedStyle(canvas).color
  and getPropertyValue on that custom property and assigns both strings straight
  to fillStyle, with alpha on globalAlpha. A custom property's computed value
  already has its var() references substituted, so what comes back is a resolved
  colour. Never hand-parse a colour: passing the computed string through means
  any syntax the browser understands works (oklch, color-mix, a brand colour a
  consumer parked behind the token). No surface probe is needed here — unlike a
  starfield, both tokens already invert with the theme, so the field is bright
  type on a dark card and dark type on a light one.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
  padding would offset the box); its first callback is the initial sizing. Try
  observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
  browsers that do not know that box throw a WebIDL TypeError from observe()
  rather than ignoring it — and fall back to observe(canvas). devicePixelRatio
  (capped at 2) is the authority on scale; the device-pixel box is used only
  when it agrees with it to within 0.01, purely to absorb sub-pixel rounding at
  1.25x/1.5x. Blurry text is far more offensive than blurry dots. Re-apply
  ctx.setTransform after every resize (writing canvas.width resets the context)
  and re-measure the font. Existing columns keep falling: heads are rescaled by
  the row ratio and cells are only reallocated when the row count changed.
- Fonts: the canvas is font-mono and the family is read from its own computed
  style, so the effect uses the project's mono stack. Re-measure once on
  document.fonts.ready — a webfont landing after first paint changes the glyph
  advance, and without this the columns keep the fallback pitch until the next
  resize. Guard that callback with a disposed flag set in cleanup.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
  on screen, document.visibilityState is "visible", motion is allowed and speed
  is above 0. dt is clamped to 1/30s so a backgrounded tab cannot teleport the
  field on resume, and the time base resets when the loop restarts.
- Theme flips: a MutationObserver on html (class/style/data-theme) re-reads both
  inks, repaints the still frame when the loop is paused, and schedules one more
  read ~400ms later — a palette animated with transition-colors reports
  intermediate values for a few hundred ms, which is long enough for a frozen
  field to latch the wrong colour forever.
- prefers-reduced-motion: reduce — read it through useSyncExternalStore (server
  snapshot false, so it is hydration-safe) and keep it in the effect deps. Under
  reduce the loop never starts and exactly one frame is painted: a full field of
  streams caught at different heights, with heads and fading tails. Never a
  blank box.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
  MutationObserver, the settle timeout and the visibilitychange listener.

Rendering & styling
- Semantic tokens only, zero colour literals: var(--primary), var(--foreground),
  var(--muted-foreground) and var(--chart-1) .. var(--chart-5), all resolved
  through the canvas's own computed style. Alpha lives in globalAlpha, never in
  the colour string.
- Merge the consumer className via cn() on the root; the canvas keeps its own
  classes.
- Accessibility: the canvas is aria-hidden and pointer-events-none — the glyphs
  are decoration, not content, and must never be announced or selected.
  Children stay fully interactive above the field, and nothing here traps
  scroll or focus.

Customization levers
- Palette: tone and headTone are the entire look. chart-2 is the classic green
  in most themes, primary keeps it on brand, muted turns it into background
  noise; headTone is what makes the leading glyph pop out of its own trail.
  opacity is the legibility lever when real body copy sits on top.
- Alphabet: charset for the built-ins, glyphs for anything else — brand
  initials, runes, punctuation, even a single character for a dot-matrix look
  (swapping switches itself off below two glyphs).
- Cost: fontSize is the real density knob, since column count scales with
  1 / fontSize. GLYPH_BUDGET (2600 fills/frame) is the safety valve — halve it
  for phones, raise it for a small hero; MAX_COLUMNS (200) caps how fine the
  grid may get, and dropping MAX_DPR to 1 halves the fill cost on retina.
- Motion: MIN_RATE / MAX_RATE (5..13 rows/s) set how much column speeds differ —
  narrow them for a marching uniform curtain, widen them for chaos. FADE_EXP
  (1.5) decides how tightly the light hugs the head, TRAIL_JITTER (0.55) how
  uneven the trail lengths are, swapRate how nervous the glyphs look.
- Typeface: the canvas class is font-mono; swap it for any font class you like.
  A proportional face still works because the pitch is measured, not assumed.
- Structure: keep the children wrapper for a hero with a headline in it, or
  render the canvas alone as a pure overlay behind existing content.

Concepts

  • Brightness envelope, not a scroll — glyphs sit in fixed grid cells; what moves down the column is the alpha ramp, brightest at the head and fading over the trail length. Sliding the glyphs themselves would read as scrolling wallpaper within a second, which is the single most common way this effect fails.
  • In-place glyph swap — a per-column timer rewrites one random glyph inside the lit trail a few times a second, and always picks a different glyph than the one already there. That is what sells "the stream is changing"; with a two-glyph alphabet a naive random draw would be invisible half the time.
  • Per-column state array — one object per column holding head, rate, trail length and a Uint8Array of glyph indices, plus its own LCG. Hundreds of glyphs move with zero DOM nodes and zero allocation per frame; arrays are only reallocated when the row count actually changes.
  • Density as vertical gapdensity is not an opacity or a column count, it is how far above the top edge a finished stream waits before falling again: gap = rows * (1 / density - 1). Density 1 restarts immediately and reads as unbroken rain.
  • Glyph budget — text is the most expensive thing a 2D canvas draws, so the effective trail length is clamped to budget / columns. Cost stays bounded no matter how small the font is, and the knob a consumer turns for performance is a single number.
  • Composed still frame — heads are seeded across the entire fall cycle rather than at the top, so the very first painted frame is already a full field. Under prefers-reduced-motion that frame is all you get, and it still looks deliberate.

On This Page