Skip to main content

ns-ui / loud

Bitplane Cascade

A full-bleed hero/background that composites a real Amiga-class planar framebuffer image one bitplane at a time: a coarse 2-tone silhouette lands first, then three more binary-weighted planes each add a finer layer of tonal detail on top of it, before the full 16-level image holds and the stack clears to rebuild from plane 0 again.

Usage

registry/loud/bitplane-cascade/demo.tsx
"use client";

import { BitplaneCascade } from "./component";

export default function BitplaneCascadeDemo() {
  return (
    <div className="relative h-screen w-full overflow-hidden bg-background">
      <div className="absolute inset-0">
        <BitplaneCascade />
      </div>
      <div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-start gap-3 p-10 text-left">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / bitplane-cascade
        </p>
        <h1 className="max-w-xl text-4xl font-semibold tracking-tight text-foreground">
          Four planes, one image
        </h1>
        <p className="max-w-sm text-sm text-ns-muted">
          A coarse silhouette resolves into finer tonal bands as each bitplane
          composites in, MSB first — then the stack clears and rebuilds.
        </p>
      </div>
    </div>
  );
}

Component

registry/loud/bitplane-cascade/component.tsx
"use client";

import { useEffect, useRef } from "react";
import type { CSSProperties, ReactNode } from "react";

// ---------------------------------------------------------------------------
// BitplaneCascade — a full-bleed background modeling Amiga-class planar
// framebuffers (and 1-bit fax/plotter graphics before them): an N-bit image
// isn't stored as one array of pixel values, it's N separate 1-bit bitplanes,
// each full-resolution, composited by adding each plane's binary contribution
// at its place value (plane weighted 8, 4, 2, 1 for a 4-bit / 16-level image).
// This component samples ONE static scalar field once per size, quantizes it
// to a 16-level (4-bit) value per grid cell, and reveals that value's bits one
// plane at a time — MOST significant place value first. That ordering is the
// deliberate, historically-accurate choice: real progressive bit-plane
// transmission (fax/plotter preview passes, progressive image codecs) sends
// the MSB plane first because it alone reconstructs a legible coarse
// silhouette; sending the LSB first would just be spatial noise with no
// silhouette to build on. So "plane 0" here is place value 8 (arrives first,
// t=0), "plane 1" is place value 4 (t=350ms), "plane 2" is place value 2
// (t=700ms), "plane 3" is place value 1, the finest bit (t=1050ms) — each
// arrival ANDs one more high bit into the visible reconstruction, so the
// already-revealed hard silhouette edge never moves, only finer bands appear
// inside/around it. Full stack holds 900ms, then the accumulator clears and
// plane 0 lands again: 350*4 + 900 = 2300ms per cycle, forever.
//
// Differentiation: background-halftone-rosette is also a full-bleed canvas
// reveal, but its identity is continuous same-ink dot-angle drift with no
// discrete steps — pick that for a texture that's always mid-motion. Pick
// bitplane-cascade when the point is a countable, steppy "one more layer just
// landed" beat with a held clean frame, not a continuous drift.
//
// ALIVE AT REST, not aliased to the checkpoint clock: within any single
// plane's 350ms window the cell states are static (a real bitplane compositor
// doesn't blend), so a bare step function would make t0 and any other time
// that lands in the same window of a later cycle read as pixel-identical.
// Instead, every cell touched by the most-recently-landed plane carries a
// short landing flash (an extra brightness pop that decays over ~220ms) — the
// legibility cue for "a layer just arrived", and it also means elapsed time
// since that landing (not just which plane is current) is always part of the
// rendered frame, so no two real-time samples within a cycle are identical.
//
// Tokens: --foreground is the only ink (mixed toward --background by opacity,
// never a literal color); --background clears the frame each draw. No
// --ns-accent anywhere — this is a resting ambient surface, not an
// interaction moment.
// ---------------------------------------------------------------------------

const STEP_MS = 350; // per-plane arrival interval
const HOLD_MS = 900; // full-stack clean-image hold after the last plane lands
const CYCLE_MS = STEP_MS * 4 + HOLD_MS; // 2300ms
const FLASH_MS = 220; // landing-flash decay window, inside one 350ms step
const PLANE_BITS = [8, 4, 2, 1]; // MSB-first arrival order (place values)

function hash2(ix: number, iy: number, seed: number) {
  let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 2246822519);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  h = h ^ (h >>> 16);
  return (h >>> 0) / 4294967295;
}

function smooth(t: number) {
  return t * t * t * (t * (t * 6 - 15) + 10);
}

function valueNoise(x: number, y: number, seed: number) {
  const x0 = Math.floor(x);
  const y0 = Math.floor(y);
  const sx = smooth(x - x0);
  const sy = smooth(y - y0);
  const n00 = hash2(x0, y0, seed);
  const n10 = hash2(x0 + 1, y0, seed);
  const n01 = hash2(x0, y0 + 1, seed);
  const n11 = hash2(x0 + 1, y0 + 1, seed);
  const nx0 = n00 + (n10 - n00) * sx;
  const nx1 = n01 + (n11 - n01) * sx;
  return nx0 + (nx1 - nx0) * sy;
}

export interface BitplaneCascadeProps {
  /** field cell pitch as a fraction of the container's smaller dimension, before clamping. @default 1/96 */
  cellRatio?: number;
  /** freeze the field at its reduced-motion frame. @default false */
  paused?: boolean;
  children?: ReactNode;
  className?: string;
  style?: CSSProperties;
}

export function BitplaneCascade({
  cellRatio = 1 / 96,
  paused = false,
  children,
  className = "",
  style,
}: BitplaneCascadeProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // token fields start empty and are assigned unconditionally from
    // getComputedStyle before any draw path can run — no literal fallback.
    let ink = "";
    let bg = "";
    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      ink = cs.getPropertyValue("--foreground").trim();
      bg = cs.getPropertyValue("--background").trim();
    };

    let dpr = 1;
    let width = 0;
    let height = 0;
    let sized = false;
    let ready = false;
    let disposed = false;
    let visible = true;
    let raf = 0;

    let cols = 0;
    let rows = 0;
    let cell = 8;
    let field: Uint8Array = new Uint8Array(0); // per-cell quantized 0..15 value
    // deterministic fixed seed; the field is sampled once per size and never
    // rerolled, so no per-mount randomization is needed here.
    const FIELD_SEED = 0x51ed270b;

    let startTime = 0;

    const buildField = () => {
      const minDim = Math.min(width, height) || 1;
      // clamp per spec: clamp(round(minDim/96), 4, 12)
      cell = Math.max(4, Math.min(12, Math.round(minDim * cellRatio)));
      cols = Math.max(1, Math.ceil(width / cell));
      rows = Math.max(1, Math.ceil(height / cell));
      field = new Uint8Array(cols * rows);

      const s = FIELD_SEED;
      const minGrid = Math.max(1, Math.min(cols, rows));
      // two octaves of hashed value noise -> large soft shapes, not fine grain
      const wave1 = minGrid / 3.2;
      const wave2 = minGrid / 9;
      const raw = new Float32Array(cols * rows);
      let minV = Infinity;
      let maxV = -Infinity;
      for (let ry = 0; ry < rows; ry++) {
        for (let rx = 0; rx < cols; rx++) {
          const n1 = valueNoise(rx / wave1, ry / wave1, s);
          const n2 = valueNoise(rx / wave2, ry / wave2, s + 97);
          const v = n1 * 0.7 + n2 * 0.3;
          raw[ry * cols + rx] = v;
          if (v < minV) minV = v;
          if (v > maxV) maxV = v;
        }
      }
      // stretch to the field's own full observed range before quantizing —
      // two blended octaves cluster toward the middle, and without this the
      // 16-level output never reaches its low/high bins, so the "full
      // luminance gradient" the cascade builds toward would never arrive.
      const range = Math.max(1e-6, maxV - minV);
      for (let i = 0; i < raw.length; i++) {
        field[i] = Math.max(0, Math.min(15, Math.floor(((raw[i] - minV) / range) * 15.999)));
      }
    };

    // returns { count: 1..4, mask: bits revealed so far, sinceLanding: ms }
    const phaseAt = (elapsed: number) => {
      const t = ((elapsed % CYCLE_MS) + CYCLE_MS) % CYCLE_MS;
      let count: number;
      let landAt: number;
      if (t < STEP_MS) {
        count = 1;
        landAt = 0;
      } else if (t < STEP_MS * 2) {
        count = 2;
        landAt = STEP_MS;
      } else if (t < STEP_MS * 3) {
        count = 3;
        landAt = STEP_MS * 2;
      } else {
        count = 4;
        landAt = STEP_MS * 3;
      }
      let mask = 0;
      for (let i = 0; i < count; i++) mask |= PLANE_BITS[i];
      const newestBit = PLANE_BITS[count - 1];
      const sinceLanding = t - landAt;
      return { count, mask, newestBit, sinceLanding };
    };

    // reused across draws, sized to the largest possible bucket count so a
    // resize never reallocates mid-frame: 16 reconstructed values x 2
    // (flashed / not) = 32 buckets, each a flat list of cell coordinate pairs.
    const bucketCells: number[][] = Array.from({ length: 32 }, () => []);
    const bucketAlpha = new Float32Array(32);

    const draw = (elapsed: number, forceFlash?: number) => {
      // no paint before the first token read — an empty getPropertyValue
      // string would otherwise leave ctx.fillStyle at its default black.
      if (!sized || !ink || !bg) return;
      const { mask, newestBit, sinceLanding } = phaseAt(elapsed);
      const flashT = forceFlash !== undefined ? forceFlash : sinceLanding;
      const flashAmt = Math.max(0, 1 - flashT / FLASH_MS);

      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, width, height);

      // bucket cells by their exact alpha (at most 32 distinct values per
      // frame: 16 reconstructed levels x flashed/not) so globalAlpha is only
      // set once per bucket instead of once per cell.
      let bucketCount = 0;
      for (let b = 0; b < bucketCells.length; b++) bucketCells[b].length = 0;

      for (let ry = 0; ry < rows; ry++) {
        for (let rx = 0; rx < cols; rx++) {
          const v = field[ry * cols + rx];
          const reconstructed = v & mask; // only already-arrived bits count
          if (reconstructed <= 0) continue;
          const flashed = flashAmt > 0 && (v & newestBit) !== 0;
          let alpha = reconstructed / 15;
          // plane-0-only: raise the contrast floor so the first silhouette
          // never washes out against a light background.
          if (mask === PLANE_BITS[0]) alpha = Math.max(alpha, 0.25);
          if (flashed) alpha = Math.min(1, alpha + flashAmt * 0.35);

          const key = (reconstructed - 1) * 2 + (flashed ? 1 : 0);
          if (bucketCells[key].length === 0) {
            bucketAlpha[key] = alpha;
            bucketCount++;
          }
          bucketCells[key].push(rx, ry);
        }
      }

      if (bucketCount > 0) {
        ctx.fillStyle = ink;
        for (let b = 0; b < bucketCells.length; b++) {
          const coords = bucketCells[b];
          if (coords.length === 0) continue;
          ctx.globalAlpha = bucketAlpha[b];
          for (let i = 0; i < coords.length; i += 2) {
            ctx.fillRect(coords[i] * cell, coords[i + 1] * cell, cell, cell);
          }
        }
        ctx.globalAlpha = 1;
      }
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) {
        sized = false;
        return;
      }
      width = rect.width;
      height = rect.height;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      buildField();
      sized = true;
    };

    // last plane-count actually painted — a static window (no new landing,
    // flash fully decayed) redraws nothing, since the frame is provably
    // unchanged: same field, same mask, same zero flash contribution.
    let lastDrawnCount = -1;

    const loop = (now: number) => {
      if (!visible) return;
      if (!startTime) startTime = now;
      const elapsed = now - startTime;
      const { count, sinceLanding } = phaseAt(elapsed);
      if (count !== lastDrawnCount || sinceLanding < FLASH_MS) {
        draw(elapsed);
        lastDrawnCount = count;
      }
      raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (!sized) return;
        ready = true;
        if (reduced || paused) {
          // frame: plane 2 of 4 just landed (3 bits revealed, MSB-first),
          // flash near-max — "just landed", not settled.
          draw(STEP_MS * 2, 10);
        } else {
          startTime = 0;
          lastDrawnCount = -1;
          if (visible && !raf) raf = requestAnimationFrame(loop);
        }
      }, 150);
    };

    const ro = new ResizeObserver(onResize);
    ro.observe(canvas);

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? true;
        if (visible && ready && !reduced && !paused) {
          startTime = 0;
          raf = requestAnimationFrame(loop);
        } else {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      },
      { threshold: 0 }
    );
    io.observe(canvas);

    const onVis = () => {
      if (document.hidden) {
        cancelAnimationFrame(raf);
        raf = 0;
      } else if (visible && ready && !reduced && !paused) {
        startTime = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced || paused) draw(STEP_MS * 2, 10);
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    document.fonts.ready.then(() => {
      if (disposed) return;
      readTokens();
      resize();
      if (!sized) {
        ready = true;
        return;
      }
      ready = true;
      if (reduced || paused) {
        draw(STEP_MS * 2, 10);
      } else if (!raf) {
        raf = requestAnimationFrame(loop);
      }
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [cellRatio, paused]);

  return (
    <div
      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 block h-full w-full"
      />
      {children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
    </div>
  );
}

BitplaneCascade.displayName = "BitplaneCascade";

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/bitplane-cascade.tsx, so point the import there.

Use when a full-bleed backdrop where the point is a countable, steppy 'one more layer just landed' compositing beat with a held clean frame — pick background-halftone-rosette instead when the identity should be continuous same-ink dot-angle drift with no discrete steps, or background-capillary-wick when the mechanic should read as an organic front propagating and evaporating along a fixed lattice rather than a fixed-cadence hardware compositor.

Install

npx shadcn add https://design.helpmarq.com/r/bitplane-cascade.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <BitplaneCascade cellRatio? paused? className? style? children?> as a full-bleed 2D canvas background modeling Amiga-class planar framebuffer compositing: an N-bit image is stored as N separate full-resolution 1-bit bitplanes, and the visible pixel value is the sum of each plane's binary contribution at its place value. Sample ONE static scalar field once per container size — two octaves of hashed value noise (a coarse octave at wavelength minGridDim/3.2 blended 0.7, a finer octave at minGridDim/9 blended 0.3, minGridDim = min(cols,rows) of the sample grid) — and quantize each cell to a 4-bit value (0-15, 16 levels). Sample grid: cell = clamp(round(containerMinDim/96), 4, 12) px, cols/rows = ceil(width|height / cell); render each cell as a flat unsmoothed cell x cell rect (no interpolation) so every plane's threshold edge stays a hard boundary. MSB-FIRST ARRIVAL IS THE LOAD-BEARING DECISION: planes arrive in place-value order 8, 4, 2, 1 (NOT LSB-first) — real progressive bit-plane transmission (fax/plotter preview passes) sends the most significant plane first because only it reconstructs a legible coarse silhouette on its own; sending the LSB first would just be spatial noise. Plane 0 (place value 8) arrives at t=0, plane 1 (value 4) at t=350ms, plane 2 (value 2) at t=700ms, plane 3 (value 1, the finest bit) at t=1050ms; each arrival ANDs one more high bit into the visible reconstruction (reconstructed = fieldValue & maskOfArrivedBits), so the already-revealed silhouette edge never moves, only finer bands fill in around it. Full stack holds 900ms once complete, then the accumulator clears and plane 0 lands again: cycle length 350*4 + 900 = 2300ms, unbounded loop, zero input. LANDING FLASH SOLVES A REAL ALIASING BUG: within a single plane's 350ms window cell states are static (a real compositor doesn't blend), so a bare step function makes two arbitrary real-time samples that land in the same window pixel-identical, which fails the alive-at-rest checkpoint rule; every cell touched by the most-recently-landed plane's bit gets an extra brightness pop (+alpha up to 0.35, decaying linearly over 220ms) that makes elapsed-time-since-landing part of every frame, not just which plane is current. Cell alpha = (reconstructed / 15), drawn as --foreground at that globalAlpha over a --background-cleared frame (never a literal color, never --ns-accent). Light theme: when only plane 0 has arrived (mask === 8) and the theme is light (per-mount luminance compare of --foreground vs --background via getComputedStyle, re-read on documentElement class MutationObserver), floor alpha at 0.25 so the first silhouette never washes out. prefers-reduced-motion and the paused prop both freeze on plane 2 of 4 just landed (3 bits revealed: 8+4+2, finest bit still pending) with the landing flash held near-max, not the full-stack hold and not plane 0 alone — the frame that most clearly shows a coarse silhouette with partially-resolved finer banding and one more step visibly pending. Tokens read via getComputedStyle(document.documentElement) with no paint before the first read; ResizeObserver rebuilds the field (debounced 150ms) and IntersectionObserver/visibilitychange pause the rAF loop off-screen/hidden; all observers and the rAF are torn down on unmount. Props: cellRatio (fraction of container's smaller dimension used before the 4-12px clamp, default 1/96), paused, className, style, children (rendered above the canvas for overlay content).

Props

PropTypeDefaultDescription
cellRatio?number1 / 96field cell pitch as a fraction of the container's smaller dimension, before clamping. @default 1/96
paused?booleanfalsefreeze the field at its reduced-motion frame. @default false
children?ReactNode——
className?string——
style?CSSProperties——