Skip to main content

ns-ui

Stat Tile ASCII Arrive

A KPI number whose arrival is ink condensing into a glyph: every dot of a 5x7 ASCII dot-matrix digit starts at a random density-ramp step and eases independently toward its true value, so the numeral visibly resolves out of noise instead of sliding or flipping into place.

Usage

registry/core/stat-tile-ascii-arrive/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { StatTileAsciiArrive } from "./component";

// Ticks each tile to a new value on a loop, held back well past the gate's
// ~1s idle screenshot so the resting frame always shows fully condensed
// digits, never mid-condensation ink.
export default function StatTileAsciiArriveDemo() {
  const [mrr, setMrr] = useState(48200);
  const [latency, setLatency] = useState(112);
  const [uptime, setUptime] = useState(99.95);

  useEffect(() => {
    const start = setTimeout(() => {
      const id = setInterval(() => {
        setMrr((v) => (v === 48200 ? 52900 : 48200));
        setLatency((v) => (v === 112 ? 87 : 112));
        setUptime((v) => (v === 99.95 ? 99.99 : 99.95));
      }, 3200);
      return () => clearInterval(id);
    }, 4000);
    return () => clearTimeout(start);
  }, []);

  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="grid w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-3">
        <div className="rounded-md border border-border bg-surface p-5">
          <StatTileAsciiArrive value={mrr} label="mrr · usd" />
        </div>
        <div className="rounded-md border border-border bg-surface p-5">
          <StatTileAsciiArrive value={latency} label="p95 latency" suffix="ms" />
        </div>
        <div className="rounded-md border border-border bg-surface p-5">
          <StatTileAsciiArrive value={uptime} label="uptime" suffix="%" />
        </div>
      </div>
    </main>
  );
}

Component

registry/core/stat-tile-ascii-arrive/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// StatTileAsciiArrive — a KPI number that arrives as ink condensing into a
// glyph, not as a digit sliding into place (that job belongs to
// counter-carry-ripple). Each character of the formatted value is rendered
// on a fixed 5x7 dot-matrix bitmap (the same convention as a classic LED
// segment display, defined once as a lookup table). On mount, and again on
// every value change, every "on" cell of the target bitmap starts from a
// RANDOM step of a density ramp and every "off" cell starts near the ramp's
// empty end; each cell then eases independently (its own random start delay
// + duration jitter) toward its true target step, so the numeral visibly
// CONDENSES out of noise instead of just fading or sliding in. Once every
// cell has reached its target the canvas stops redrawing entirely — this is
// a display-only component, so there is no reason to keep painting an
// identical frame forever.
//
// Pure <canvas>, cell coloring read via getComputedStyle so both themes are
// correct without a single hardcoded hex. prefers-reduced-motion renders the
// settled glyph immediately, no condensation pass.
// ---------------------------------------------------------------------------

const RAMP = " .:-=+*#%@"; // 10-step density ramp, index 0 = empty
const CELL_W = 5;
const CELL_H = 7;
const SETTLE_MS = 620; // worst-case per-cell settle time
const MAX_STAGGER_MS = 220; // spread of per-cell start delay

// 5x7 dot-matrix glyphs, 1 = ink. Minimal set: digits, separators, sign.
const GLYPHS: Record<string, string[]> = {
  "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
  "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
  "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
  "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
  "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
  "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
  "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
  "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
  "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
  "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
  ",": ["00000", "00000", "00000", "00000", "00000", "00100", "01000"],
  ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"],
  "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
  "+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"],
  "%": ["11001", "11010", "00100", "01000", "10011", "10011", "00000"],
  " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
};

function glyphRows(ch: string): string[] {
  return GLYPHS[ch] ?? GLYPHS[" "]!;
}

interface CellAnim {
  target: number; // 0 or 1 (ink or not)
  from: number; // starting ramp index
  delay: number; // ms
  dur: number; // ms
}

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function easeOutCubic(t: number) {
  return 1 - Math.pow(1 - t, 3);
}

export interface StatTileAsciiArriveProps {
  /** formatted or raw value; numbers get toLocaleString, strings pass through */
  value: number | string;
  /** caption above the readout */
  label?: string;
  /** shown after the value, e.g. "%" or "ms" */
  suffix?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function StatTileAsciiArrive({
  value,
  label,
  suffix = "",
  className = "",
}: StatTileAsciiArriveProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const seedRef = useRef(0);

  const formatted =
    typeof value === "number" ? value.toLocaleString() : value;
  const text = `${formatted}${suffix}`;

  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;
    seedRef.current += 1;
    const rand = mulberry32(0x9e3779b1 + seedRef.current * 101);

    let fgCss = "currentColor";
    let disposed = false;
    let raf = 0;
    let start = 0;

    const readTokens = () => {
      fgCss = getComputedStyle(canvas).color;
    };
    const fontFamily = getComputedStyle(canvas).fontFamily;

    const chars = text.split("");
    const glyphW = CELL_W;
    const gapCols = 1; // gap between characters, in dot columns
    const totalCols = chars.reduce((sum, c, i) => sum + glyphW + (i > 0 ? gapCols : 0), 0);
    const MAX_DOT_PX = 5; // px per dot cell at full size (before dpr scaling)
    const MAX_DOT_GAP = 1.4;
    const MIN_DOT_PX = 2.4; // never shrink glyphs illegibly small

    // scale the whole dot-matrix down to fit whatever width the card
    // actually has available — a long value (e.g. "48,200") in a narrow
    // tile must never overflow its card, and a stat tile's value length is
    // arbitrary, so this can't be a fixed constant
    const available = canvas.parentElement?.getBoundingClientRect().width || 0;
    const idealPitch = MAX_DOT_PX + MAX_DOT_GAP;
    const neededW = (totalCols + 1) * idealPitch;
    const scale = available > 0 ? Math.min(1, available / neededW) : 1;
    const dotPx = Math.max(MIN_DOT_PX, MAX_DOT_PX * scale);
    const dotGap = MAX_DOT_GAP * scale;
    const pitch = dotPx + dotGap;

    let dpr = 1;
    const resize = () => {
      const w = totalCols * pitch + pitch;
      const h = CELL_H * pitch + pitch * 2;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.font = `${dotPx * 1.7}px ${fontFamily}`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
    };
    resize();

    // build the per-cell animation plan across the whole string
    const cols = totalCols;
    const rows = CELL_H;
    const cells: CellAnim[] = new Array(cols * rows);
    let colCursor = 0;
    chars.forEach((ch, ci) => {
      const rowsFor = glyphRows(ch);
      for (let r = 0; r < rows; r++) {
        const bits = rowsFor[r]!;
        for (let c = 0; c < glyphW; c++) {
          const target = bits[c] === "1" ? 1 : 0;
          const idx = r * cols + (colCursor + c);
          cells[idx] = {
            target,
            from: target === 1 ? Math.floor(rand() * (RAMP.length - 2)) : RAMP.length - 1 - Math.floor(rand() * 2),
            delay: rand() * MAX_STAGGER_MS,
            dur: SETTLE_MS * (0.75 + rand() * 0.35),
          };
        }
      }
      colCursor += glyphW + (ci < chars.length - 1 ? gapCols : 0);
    });

    const cellX = (c: number) => pitch / 2 + c * pitch + dotPx / 2;
    const cellY = (r: number) => pitch + r * pitch + dotPx / 2;

    const drawStatic = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = fgCss;
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const cell = cells[r * cols + c];
          if (!cell || cell.target === 0) continue;
          ctx.globalAlpha = 1;
          ctx.fillText(RAMP[RAMP.length - 1]!, cellX(c), cellY(r));
        }
      }
      ctx.globalAlpha = 1;
    };

    // draws the character at ramp index `step` (rounded) with alpha keyed to
    // how far into its ink range that index sits, so the transition between
    // adjacent glyphs still reads as a continuous density ramp rather than
    // a visible per-frame glyph pop
    const drawFrame = (elapsed: number) => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = fgCss;
      let allDone = true;
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const cell = cells[r * cols + c];
          if (!cell) continue;
          const local = elapsed - cell.delay;
          let step: number;
          if (local <= 0) {
            step = cell.from;
            allDone = false;
          } else if (local >= cell.dur) {
            step = cell.target === 1 ? RAMP.length - 1 : 0;
          } else {
            const t = easeOutCubic(local / cell.dur);
            step = cell.from + (cell.target === 1 ? RAMP.length - 1 - cell.from : 0 - cell.from) * t;
            allDone = false;
          }
          const idx = Math.max(0, Math.min(RAMP.length - 1, Math.round(step)));
          if (idx === 0) continue;
          ctx.globalAlpha = 0.4 + (idx / (RAMP.length - 1)) * 0.6;
          ctx.fillText(RAMP[idx]!, cellX(c), cellY(r));
        }
      }
      ctx.globalAlpha = 1;
      return allDone;
    };

    readTokens();

    if (reduced) {
      drawStatic();
    } else {
      const loop = (now: number) => {
        if (disposed) return;
        if (!start) start = now;
        const elapsed = now - start;
        const done = drawFrame(elapsed);
        if (!done) {
          raf = requestAnimationFrame(loop);
        }
      };
      raf = requestAnimationFrame(loop);
    }

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) drawStatic();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      mo.disconnect();
    };
    // re-run the whole condensation whenever the displayed text changes
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [text]);

  return (
    <div className={`flex w-full min-w-0 flex-col items-start gap-2 ${className}`}>
      {label && (
        <span className="font-mono text-[10px] uppercase tracking-widest text-ns-muted">{label}</span>
      )}
      <canvas ref={canvasRef} aria-hidden data-stat-canvas className="block font-mono" />
      <span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
        {label ? `${label}: ${text}` : text}
      </span>
    </div>
  );
}

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

Use when a KPI/stat tile whose number should visibly resolve out of noise on arrival — ASCII ink density condensing per-dot into the digit — rather than sliding digit columns; pick counter-carry-ripple instead for a live readout where the STRUCTURE of the change (carries rippling right to left) matters more than a one-time arrival.

Install

npx shadcn add https://design.helpmarq.com/r/stat-tile-ascii-arrive.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

Renders `value` (a number or preformatted string, optionally with a `suffix` like '%' or 'ms') as ASCII ink on a small <canvas>, one 5x7 dot-matrix bitmap per character from a fixed lookup table (digits 0-9, comma, period, minus, plus, percent, space) — the same convention as a classic LED segment display, defined once as string rows of '0'/'1'. On mount, and again whenever the formatted text changes, every dot cell across the WHOLE string is assigned an independent animation: cells that are 'ink' in the target glyph start at a random low step of a 10-step density ramp (' .:-=+*#%@') and ease up to the ramp's densest character; cells that are 'off' start near the ramp's sparse end and ease down to nothing. Each cell gets its own random start delay (0-220ms) and duration jitter (~620ms nominal, +/-17%) via a seeded PRNG, so cells don't all move in lockstep — the numeral visibly CONDENSES out of a field of noise rather than fading or sliding in as a block. Mid-transition, a cell's displayed character is the ramp glyph at its rounded current step with alpha keyed to how far into that step's range it sits, so the eye reads a continuous density gradient rather than discrete glyph pops. Once every cell has reached its target the canvas stops issuing rAF frames entirely (display-only, no reason to keep redrawing an identical frame). ACCESSIBILITY: the canvas is aria-hidden; a visually-hidden role=status aria-live=polite aria-atomic=true span holds the plain locale-formatted value (with label, if provided) as the accessible content. REDUCED MOTION: renders the fully settled glyph on the first frame, no condensation pass, no rAF loop. Ink color is read via getComputedStyle(canvas).color at mount and re-derived on a documentElement class MutationObserver so both themes render correctly with zero hardcoded hex. Zero dependencies, canvas + inline font metrics only.

Props

PropTypeDefaultDescription
valuenumber | string—formatted or raw value; numbers get toLocaleString, strings pass through
label?string—caption above the readout
suffix?string—shown after the value, e.g. "%" or "ms"
className?string—extra classes merged onto the rendered root element