Cascade
Digital code rain: monospace glyph columns fall with bright white-hot heads and exponentially-fading cyan-steel trails, glyphs scrambling as the light passes, layered by depth. Pale-phosphor default (classic green is a preset). Canvas 2D, seeded, offscreen-paused, reduced-motion-safe.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface CascadeProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Two tones `[head, trail]`. `head` is the bright leading glyph, `trail` is
* the color the exponential tail fades toward. Defaults to pale phosphor:
* near-white heads melting into a cool cyan-steel trail. For the classic
* green rain pass `["#d6ffe0", "#22c55e"]`.
* @default ["#eafffb", "#38c7d6"]
*/
colors?: [string, string];
/** Fall-speed multiplier. 1 = default, 2 = twice as fast. @default 1 */
speed?: number;
/** Overall glyph brightness / bloom, 0–2. @default 1 */
intensity?: number;
/** Glyph size in CSS px — also the column width. Clamped 8–48. @default 16 */
fontSize?: number;
/** devicePixelRatio ceiling (text-heavy, so kept modest). Clamped 1–2. @default 2 */
dpr?: number;
/** Freeze the rain at a static, legible frame. @default false */
paused?: boolean;
}
const DEFAULT_COLORS: [string, string] = ["#eafffb", "#38c7d6"];
/** Deterministic per-column PRNG so streams never desync SSR/CSR. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Fixed seed base — the frame is identical on server-render intent and client. */
const SEED = 0x9e3779b9;
/** Clean techy glyph set: half-width katakana + digits + symbols + a few caps. */
const GLYPHS: string[] = (() => {
const chars: string[] = [];
for (let c = 0xff66; c <= 0xff9d; c++) chars.push(String.fromCharCode(c));
for (const s of "0123456789") chars.push(s);
for (const s of "=+-*/<>|:.^~") chars.push(s);
for (const s of "ABCDEFHKMNPRSTXZ") chars.push(s);
return chars;
})();
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "").trim();
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
const int = parseInt(h, 16);
if (Number.isNaN(int) || h.length !== 6) return [234, 255, 251];
return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}
interface Column {
rng: () => number;
/** Depth 0..1 — nearer columns fall faster, brighter, longer. */
depth: number;
/** Cells per second (pre speed-multiplier). */
speed: number;
/** Trail length in cells. */
len: number;
/** Extra-luminous "hero" column. */
bright: boolean;
/** Head position in cell-rows (float, can be negative before entering). */
head: number;
/** Frozen head position for the reduced-motion / paused frame. */
staticHead: number;
/** One glyph index per screen row; mutates as the light passes. */
glyphs: Int16Array;
}
interface Internals {
ctx: CanvasRenderingContext2D;
cell: number;
cols: number;
rows: number;
xOff: number;
width: number;
height: number;
columns: Column[];
head: [number, number, number];
trail: [number, number, number];
speed: number;
intensity: number;
fontSize: number;
bg: string;
}
/** Build the columns for a given grid, deterministically from the seed. */
function buildColumns(cols: number, rows: number): Column[] {
const out: Column[] = [];
for (let i = 0; i < cols; i++) {
const rng = mulberry32(SEED + Math.imul(i + 1, 0x27d4eb2f));
const depth = rng();
const span = rows + 4;
const glyphs = new Int16Array(Math.max(rows, 1));
for (let r = 0; r < glyphs.length; r++) glyphs[r] = (rng() * GLYPHS.length) | 0;
out.push({
rng,
depth,
speed: 4 + depth * 13, // nearer = faster
len: Math.round(8 + rng() * 22 + depth * 6),
bright: rng() < 0.13,
head: -rng() * span, // staggered vertical start for depth
staticHead: rng() * span, // legible frozen frame
glyphs,
});
}
return out;
}
function respawn(c: Column, rows: number) {
c.depth = c.rng();
c.speed = 4 + c.depth * 13;
c.len = Math.round(8 + c.rng() * 22 + c.depth * 6);
c.bright = c.rng() < 0.13;
c.head = -c.rng() * 6 - 1; // re-enter just above the top
// Freshly scramble a few glyphs so the returning stream isn't a repeat.
for (let k = 0; k < 6; k++) {
const r = (c.rng() * rows) | 0;
if (r >= 0 && r < c.glyphs.length) c.glyphs[r] = (c.rng() * GLYPHS.length) | 0;
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/**
* Render one frame. `useStatic` freezes every column at its `staticHead` and
* skips glyph mutation — the reduced-motion / paused frame.
*/
function draw(it: Internals, dt: number, useStatic: boolean) {
const { ctx, cell, rows, xOff, columns, head, trail } = it;
// Opaque near-black repaint (cool cast so the frame never reads dead #000).
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = it.bg;
ctx.fillRect(0, 0, it.width, it.height);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `${it.fontSize}px ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace`;
const gAmt = Math.max(it.intensity, 0);
// ------------------------------------------------------------- trail pass --
// Every glyph except the glowing head, drawn cheaply with source-over.
for (let i = 0; i < columns.length; i++) {
const c = columns[i];
const cx = xOff + i * cell + cell * 0.5;
if (!useStatic) {
c.head += c.speed * it.speed * dt;
if (c.head - c.len > rows + 1) respawn(c, rows);
// Head scramble + occasional trail mutation, deterministic via rng.
const hr = Math.floor(c.head);
if (hr >= 0 && hr < c.glyphs.length && c.rng() < 0.4) {
c.glyphs[hr] = (c.rng() * GLYPHS.length) | 0;
}
if (c.rng() < 0.09) {
const r = (c.rng() * c.glyphs.length) | 0;
c.glyphs[r] = (c.rng() * GLYPHS.length) | 0;
}
}
const headPos = useStatic ? c.staticHead : c.head;
const trailK = 4 / Math.max(c.len, 1);
const colAlpha = (0.5 + c.depth * 0.5) * (c.bright ? 1 : 0.82);
const r0 = Math.round(headPos);
const rTop = r0 - c.len;
for (let r = r0 + 1; r >= rTop; r--) {
if (r < 0 || r >= c.glyphs.length) continue;
if (r === r0) continue; // the head is drawn in the glow pass
const d = headPos - r;
// Long soft tail above the head, sharp cutoff below it.
const a = (d >= 0 ? Math.exp(-d * trailK) : Math.exp(d * 3.0)) * colAlpha * gAmt;
if (a < 0.015) continue;
const t = Math.min(Math.max(d / c.len, 0), 1);
const rr = lerp(head[0], trail[0], t);
const gg = lerp(head[1], trail[1], t);
const bb = lerp(head[2], trail[2], t);
ctx.fillStyle = `rgba(${rr | 0}, ${gg | 0}, ${bb | 0}, ${Math.min(a, 1)})`;
ctx.fillText(GLYPHS[c.glyphs[r]], cx, r * cell + cell * 0.5);
}
}
// -------------------------------------------------------------- head pass --
// Additive glow on the brightest glyph only — tasteful bloom, no wash.
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < columns.length; i++) {
const c = columns[i];
const headPos = useStatic ? c.staticHead : c.head;
const r0 = Math.round(headPos);
if (r0 < 0 || r0 >= c.glyphs.length) continue;
const cx = xOff + i * cell + cell * 0.5;
const cy = r0 * cell + cell * 0.5;
// Head tone pushed toward white; hero columns go fully white-hot.
const whiten = c.bright ? 0.85 : 0.55;
const hr = lerp(head[0], 255, whiten);
const hg = lerp(head[1], 255, whiten);
const hb = lerp(head[2], 255, whiten);
const glow = (c.bright ? 1 : 0.7) * gAmt;
ctx.shadowColor = `rgba(${head[0] | 0}, ${head[1] | 0}, ${head[2] | 0}, ${Math.min(glow, 1)})`;
ctx.shadowBlur = it.fontSize * (c.bright ? 0.9 : 0.6);
ctx.fillStyle = `rgba(${hr | 0}, ${hg | 0}, ${hb | 0}, ${Math.min(0.9 * gAmt, 1)})`;
ctx.fillText(GLYPHS[c.glyphs[r0]], cx, cy);
}
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = "source-over";
}
/** Very-dark cool-tinted base derived from the trail hue — never flat #000. */
function baseFill(trail: [number, number, number]): string {
return `rgb(${(3 + trail[0] * 0.012) | 0}, ${(5 + trail[1] * 0.018) | 0}, ${(7 + trail[2] * 0.02) | 0})`;
}
/** SVG film-grain, embedded — kills banding on the glow halos. Self-contained. */
const GRAIN =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
/**
* Cascade — digital code rain, done tastefully. Columns of monospace glyphs
* fall down the frame: each a vertical stream with a bright, white-hot leading
* head and an exponentially-fading tail, glyphs scrambling as the light passes
* through them. Per-column speed, length and depth vary so the field reads
* layered; occasional hero columns burn brighter. Defaults to a pale-phosphor
* palette (near-white heads → cool cyan-steel trail on cool near-black), not
* the cliché green — pass `colors` for the classic green rain. Canvas 2D:
* seeded and SSR-safe, offscreen/hidden-tab paused, reduced-motion-safe (a
* frozen, legible frame). Drop it inside any `relative` container.
*/
export function Cascade({
colors = DEFAULT_COLORS,
speed = 1,
intensity = 1,
fontSize = 16,
dpr = 2,
paused = false,
className,
...props
}: CascadeProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const staticFrame = paused || reducedMotion;
const staticFrameRef = React.useRef(staticFrame);
staticFrameRef.current = staticFrame;
const lastElapsedRef = React.useRef(0);
const fs = Math.min(Math.max(fontSize, 8), 48);
const tick = React.useCallback((elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
let dt = elapsed - lastElapsedRef.current;
lastElapsedRef.current = elapsed;
// Clamp long gaps (tab restore) so streams don't teleport.
if (dt > 0.1) dt = 0.1;
if (dt < 0) dt = 0;
draw(it, dt, staticFrameRef.current);
}, []);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const effectiveDpr = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
Math.min(Math.max(dpr, 1), 2)
);
const trail = hexToRgb(colors[1]);
const it: Internals = {
ctx,
cell: fs,
cols: 0,
rows: 0,
xOff: 0,
width: 0,
height: 0,
columns: [],
head: hexToRgb(colors[0]),
trail,
speed,
intensity,
fontSize: fs,
bg: baseFill(trail),
};
internalsRef.current = it;
// 2D context-loss: preventDefault asks the browser to restore, and the
// ResizeObserver repaint below refills the canvas once it comes back.
const onContextLost = (e: Event) => {
e.preventDefault();
};
canvas.addEventListener("contextlost", onContextLost);
const resize = () => {
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
canvas.width = Math.round(w * effectiveDpr);
canvas.height = Math.round(h * effectiveDpr);
canvas.style.width = "100%";
canvas.style.height = "100%";
ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
it.width = w;
it.height = h;
it.cols = Math.max(1, Math.floor(w / fs));
it.rows = Math.max(1, Math.ceil(h / fs) + 1);
it.xOff = (w - it.cols * fs) / 2;
it.columns = buildColumns(it.cols, it.rows);
// Paint one frame immediately — never a blank canvas on mount/resize.
draw(it, 0, staticFrameRef.current);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
return () => {
ro.disconnect();
canvas.removeEventListener("contextlost", onContextLost);
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dpr, fs, colors.join(","), speed, intensity]);
// Re-render the frozen frame in place when paused/reduced-motion toggles.
React.useEffect(() => {
const it = internalsRef.current;
if (!it || !staticFrame) return;
draw(it, 0, true);
}, [staticFrame]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="cascade"
className={cn("absolute inset-0 -z-10 overflow-hidden bg-neutral-950", className)}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
{/* Soft vignette so the rain sinks into the frame edges. */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(120% 120% at 50% 42%, transparent 55%, rgba(0,0,0,0.55) 100%)",
}}
/>
{/* Embedded film-grain — dithers the glow halos, defeats 8-bit banding. */}
<div
className="pointer-events-none absolute inset-0"
style={{ backgroundImage: GRAIN, opacity: 0.04, mixBlendMode: "overlay" }}
/>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/cascadeProps
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | [string, string] | ["#eafffb", "#38c7d6"] | Two tones [head, trail]. head is the bright leading glyph, trail is the color the exponential tail fades toward. Defaults to pale phosphor: near-white heads melting into a cool cyan-steel trail. For the classic green rain pass ["#d6ffe0", "#22c55e"]. |
| speed | number | 1 | Fall-speed multiplier. 1 = default, 2 = twice as fast. |
| intensity | number | 1 | Overall glyph brightness / bloom, 0–2. |
| fontSize | number | 16 | Glyph size in CSS px — also the column width. Clamped 8–48. |
| dpr | number | 2 | devicePixelRatio ceiling (text-heavy, so kept modest). Clamped 1–2. |
| paused | boolean | false | Freeze the rain at a static, legible frame. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.