# Cascade — Animated Background for React

> 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.

- Page: https://crucibleui.com/components/backgrounds/cascade
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/cascade
```

Registry JSON: https://crucibleui.com/r/cascade.json

Import from `@/components/crucible/cascade`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Cascade } from "@/components/crucible/cascade";
import { DemoScene } from "@/components/registry/demo-scene";

export default function CascadeDemo() {
  return (
    <DemoScene
      background={<Cascade />}
      headline="Ship at the speed of thought"
      subhead="Falling glyph streams, layered by depth, behind your content."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`cascade.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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(",")]);

  // Live-sync speed / intensity without rebuilding (and resetting) the rain.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    it.speed = speed;
    it.intensity = intensity;
    if (staticFrameRef.current) draw(it, 0, true);
  }, [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>
  );
}
```
