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