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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/matrix-rain.jsonPrompt
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
Uint8Arrayof 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 gap —
densityis 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-motionthat frame is all you get, and it still looks deliberate.