ns-ui
Meter Matrix Scan
A level meter rendered as a genuinely row-multiplexed LED dot-matrix panel: five rows addressed round-robin off a real 240Hz-per-row clock, and each LED's brightness is a literal 8-step PWM duty-cycle quantization rather than a smooth analog fill. The row-address concept shows as a calm, low-amplitude brightness gradient a couple of rows wide, drifting slowly down the panel and back: legible on a second look, never a strobe.
Use when Pick meter-matrix-scan when the surface is a level/value meter that should read as genuine multiplex LED hardware — quantized PWM brightness steps and a visible row-scan artifact, distinct from meter-quota-meniscus (a calibrated liquid-rise metaphor against a scribed threshold line, no hardware-scan concept), meter-latency-capillary (a time-to-first-token capillary fill against p50/p95 marks, not a level gauge), and meter-threshold-trip (a bimetallic-strip pass/fail latch, not a continuous level readout). Pick meter-quota-rule instead when the surface is a static used/total fraction meant to repeat many times at text scale with no ongoing live read.
Install
npx shadcn add https://design.helpmarq.com /r/meter-matrix-scan.jsonSource
registry/core/meter-matrix-scan/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// MeterMatrixScan — a level meter rendered as a genuinely row-multiplexed
// LED dot-matrix panel.
//
// SOURCE, NOT INVENTED: cheap commercial LED dot-matrix signage (scrolling
// ticker boards, gym scoreboards, elevator floor indicators, budget
// character displays built on row/column multiplex driver chips) can't
// afford one continuous driver per LED. Instead the panel scans one row at
// a time at a frequency well above human flicker fusion, relying on
// persistence of vision to read a complete static image, and within each
// row's brief active slice an individual LED's apparent brightness is set
// by PWM (pulse-width modulation) duty cycle — fully on or fully off at any
// instant, perceived brightness being the fraction of the row's active
// window it spends on, quantized to a small number of duty steps. That
// quantized time-division duty cycle (not a continuous alpha/density value)
// is this component's entire identity, distinct from every other
// ASCII/glyph-luminance component in the registry.
//
// THE ROW-SCAN ARTIFACT: a real multiplex board's per-row scan is invisible
// at rest — that's the whole point of persistence of vision. It only ever
// becomes visible when something samples the panel at a rate that doesn't
// divide evenly into its own full-panel refresh, e.g. filming an LED sign
// with a camera whose shutter/frame rate doesn't line up with the panel's
// scan rate produces a soft band drifting through the image (the reason
// dashcam footage sometimes shows a faint bar through LED signage). A literal
// 1:1 real-time render of the raw ROW_SCAN_HZ clock against a ~60Hz browser
// paint rate aliases close enough to the paint rate itself to read as a hard
// strobe/flicker — a rendering-pipeline artifact, not the calm hardware
// phenomenon it's meant to represent. So the scan address (ROW_SCAN_HZ,
// documented below, is the real underlying clock) is deliberately mapped onto
// a slow, continuous sweep position instead of a discrete per-frame row
// index: a soft brightness gradient a couple of rows wide, low amplitude,
// drifting the full height of the panel and back over several seconds — the
// same round-robin row addressing concept, legible on a second look, without
// ever strobing near the paint rate.
// ---------------------------------------------------------------------------
const ROWS = 5;
const ROW_SCAN_HZ = 240; // real per-row multiplex clock this component represents
const PWM_LEVELS = 8; // 3-bit duty-cycle depth
const GUTTER_PX = 3; // gap between dots, ~2-4px per spec
const SWEEP_PERIOD_S = 7.5; // one full down-and-back sweep across the panel
const SWEEP_SIGMA_ROWS = 1.4; // gradient softness, in rows — wide, not a 1-row strip
const SCAN_HIGHLIGHT_ALPHA = 0.055; // luminance-only, low-amplitude boost at the sweep's center
const MIN_ON_ALPHA = 0.16; // floor so PWM band 1/8 never rounds to invisible in light theme
// slow generative "sensor" field used only when no external `value` prop is
// supplied — three non-commensurate traveling sine components, amplitude
// bounded so the simulated reading stays comfortably inside 0..100.
function sensorValue(t: number) {
const f =
Math.sin(t * 0.17) + 0.5 * Math.sin(t * 0.43 + 1.3) + 0.3 * Math.sin(t * 0.08 + 2.1);
const norm = f / 1.8; // amplitude sum 1.8 -> -1..1
return 55 + norm * 32; // ~23..87
}
// continuous sweep position, in row units (0..ROWS-1), a slow triangle wave
// so the drift is smooth and reverses without a jump-cut at either end
function sweepPosition(t: number) {
const phase = (t % SWEEP_PERIOD_S) / SWEEP_PERIOD_S; // 0..1
const tri = phase < 0.5 ? phase * 2 : 2 - phase * 2; // 0 -> 1 -> 0
return tri * (ROWS - 1);
}
export interface MeterMatrixScanProps {
/** current level, 0..max. Omit to drive the meter from an internal simulated sensor read. */
value?: number;
/** domain ceiling for value. Default 100. */
max?: number;
/** accessible name for the reading, e.g. "CPU load". Default "Level". */
label?: string;
/** panel height in px; ROWS=5 fixed, cell size derives as height / 5. Default 60. */
height?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function MeterMatrixScan({
value,
max = 100,
label = "Level",
height = 60,
className = "",
}: MeterMatrixScanProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const valueRef = useRef(value);
valueRef.current = value;
useEffect(() => {
const canvas = canvasRef.current;
const wrapper = wrapperRef.current;
if (!canvas || !wrapper) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let bg = "";
let fg = "";
const cellSize = Math.max(2, height / ROWS);
const radius = Math.max(1, (cellSize - GUTTER_PX) / 2);
let cols = 0;
let sized = false;
let lastWidth = 0;
const readTokens = () => {
const root = getComputedStyle(document.documentElement);
// fallbacks are CSS keywords, never literal colour values
bg = root.getPropertyValue("--background").trim() || "transparent";
fg = root.getPropertyValue("--foreground").trim() || "currentColor";
};
const resize = () => {
const { width } = canvas.getBoundingClientRect();
if (width < 2) {
sized = false;
return;
}
if (sized && Math.abs(width - lastWidth) < 1) return;
lastWidth = width;
const 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);
cols = Math.max(6, Math.floor(width / cellSize));
sized = true;
};
// globalT: seconds, drives both the simulated sensor field (when
// `value` is uncontrolled) and the sweep position. Never resets.
let globalT = 0;
const currentValue = () => {
const external = valueRef.current;
const v = external === undefined ? sensorValue(globalT) : external;
return Math.min(max, Math.max(0, v));
};
const draw = (sweepPos: number | null) => {
if (!sized) return;
const w = cols * cellSize;
ctx.clearRect(0, 0, w, height);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, height);
const filled = (currentValue() / max) * cols;
const fullCols = Math.min(cols, Math.floor(filled));
const frac = filled - fullCols;
// fractional boundary LED's brightness is a literal quantized PWM
// duty step, never a smooth alpha ramp
const boundaryLevel = Math.round(frac * PWM_LEVELS);
for (let r = 0; r < ROWS; r++) {
const cy = r * cellSize + cellSize / 2;
// soft Gaussian weight of this row against the sweep's current
// center — a wide, low-amplitude bump, not a hard on/off strip, so
// several rows share a gentle gradient rather than one strobing
let sweepBoost = 0;
if (sweepPos !== null) {
const d = r - sweepPos;
sweepBoost = SCAN_HIGHLIGHT_ALPHA * Math.exp(-(d * d) / (2 * SWEEP_SIGMA_ROWS * SWEEP_SIGMA_ROWS));
}
for (let c = 0; c < cols; c++) {
let level = 0;
if (c < fullCols) level = PWM_LEVELS;
else if (c === fullCols) level = boundaryLevel;
if (level <= 0) continue;
let alpha = level / PWM_LEVELS;
alpha = Math.max(MIN_ON_ALPHA, alpha);
alpha = Math.min(1, alpha + sweepBoost);
const cx = c * cellSize + cellSize / 2;
ctx.globalAlpha = alpha;
ctx.fillStyle = fg;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
}
// the same soft gradient laid across only the off (background)
// columns of this row — what makes the sweep legible independent of
// the value fill, luminance only, never a hard band. Confined to the
// unlit region so it never stacks on top of an already-boosted dot.
const offStartX = Math.min(w, (fullCols + 1) * cellSize);
if (sweepBoost > 0.002 && offStartX < w) {
ctx.globalAlpha = sweepBoost * 0.7;
ctx.fillStyle = fg;
ctx.fillRect(offStartX, r * cellSize, w - offStartX, cellSize);
}
}
ctx.globalAlpha = 1;
wrapper.setAttribute("aria-valuenow", String(Math.round(currentValue())));
};
// -- loop ----------------------------------------------------------------
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dtMs = last ? Math.min(250, now - last) : 1000 / 60;
last = now;
globalT += dtMs / 1000;
draw(sweepPosition(globalT));
if (!document.hidden) raf = requestAnimationFrame(loop);
};
const mo = new MutationObserver(() => {
readTokens();
if (reduced) draw(null);
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
readTokens();
resize();
draw(reduced ? null : sweepPosition(globalT));
}, 150);
};
const ro = new ResizeObserver(onResize);
ro.observe(canvas);
const io = new IntersectionObserver(
(entries) => {
const visible = entries[0]?.isIntersecting;
if (visible && !reduced && sized) {
cancelAnimationFrame(raf);
last = 0;
raf = requestAnimationFrame(loop);
} else if (!visible) {
cancelAnimationFrame(raf);
}
},
{ threshold: 0 }
);
io.observe(canvas);
const onVis = () => {
cancelAnimationFrame(raf);
if (!document.hidden && !reduced && sized) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
document.addEventListener("visibilitychange", onVis);
// no paint before the first token read
readTokens();
resize();
if (reduced) {
// freeze with the sweep locked off entirely — no gradient visible,
// every row rendered as if simultaneously lit at its true duty level —
// at a value past the field's cold-start instant, same convention as
// this registry's other generative components.
globalT = 1.4;
draw(null);
} else {
draw(sweepPosition(globalT));
raf = requestAnimationFrame(loop);
}
return () => {
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
mo.disconnect();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [height, max]);
return (
<div
ref={wrapperRef}
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
className={`ns-mms w-full ${className}`}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="block w-full"
style={{ height }}
/>
</div>
);
}
Build spec
Build <MeterMatrixScan value? max? label? height? className?> as a full-width <canvas> panel wrapped in a <div role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={max}>, a drop-in level/value meter. SOURCE, NOT INVENTED: cheap commercial LED dot-matrix signage (scrolling ticker boards, gym scoreboards, elevator floor indicators, budget character displays built on row/column multiplex driver chips) can't afford one continuous driver per LED — the panel scans one row at a time at a frequency well above human flicker fusion, relying on persistence of vision to read a complete static image, and within each row's brief active slice an individual LED's apparent brightness is set by PWM (pulse-width modulation) duty cycle: fully on or fully off at any instant, perceived brightness being the fraction of the row's active window it spends on, quantized to a small number of duty steps (a driver's PWM bit depth). That literal quantized time-division duty cycle, never a continuous alpha/density value, is this component's entire identity and must remain visibly stepped, not anti-aliased into a smooth gradient. REAL NUMBERS: ROWS=5 (fixed); ROW_SCAN_HZ=240, each row the active scan target for 1000/240 ~= 4.17ms, a full 5-row panel completing one full-frame refresh every ~20.8ms (~48Hz), comfortably above flicker fusion, matching real multiplex-board practice; PWM_LEVELS=8, a 3-bit duty-cycle depth typical of budget driver chips. GEOMETRY: cellSize = containerHeight / 5 (square cells, so exactly 5 rows fill the panel's own height); columns = floor(containerWidth / cellSize); each LED is drawn as a filled circle (canvas arc), radius = (cellSize - gutterPx) / 2, gutterPx in the 2-4px range, not a Unicode glyph. VALUE / FILL: the meter reads a single scalar 0..max mapped left-to-right across the column count — filled = (value/max) * cols; every column below the integer floor of filled is drawn at full PWM_LEVELS brightness; the single boundary column at that floor is drawn at a duty level quantized from its fractional remainder (Math.round(frac * PWM_LEVELS), 0..8 discrete steps) — this is the real technique budget LED bargraphs use to fake sub-LED resolution by PWM-dimming exactly one boundary LED rather than genuinely lighting a fractional pixel; columns past the boundary are undrawn (--background shows through, meaning fully off). SIMULATED SENSOR, WHEN value IS OMITTED: when the value prop isn't supplied, a self-contained generative field drives the reading continuously — three non-commensurate traveling sine components on the component's own never-resetting clock, amplitude-bounded to stay inside roughly 23-87 on a 0-100 domain — so the meter is a genuine 'alive at rest' ambient demo with zero external wiring; when value IS supplied, that value renders directly and is the source of truth, letting the same component serve as a real production meter. ROW-SCAN, RENDERED CALM ON PURPOSE: a real board's per-row scan is invisible at rest (that's the entire point of persistence of vision) and only becomes visible when something samples the panel at a rate that doesn't divide evenly into its own refresh rate — the well-documented reason a camera filming multiplexed LED signage sometimes shows a faint drifting band. A literal 1:1 real-time render of the raw ROW_SCAN_HZ clock against a ~60Hz browser paint rate aliases close enough to the paint rate itself to read as a hard strobe/flicker rather than that calm hardware phenomenon — a rendering-pipeline artifact, not the mechanic. So the row-address concept is deliberately mapped onto a slow, continuous sweep position instead of a discrete per-frame row index: sweepPosition(t) is a triangle wave over SWEEP_PERIOD_S=7.5s (one full down-and-back pass across the 5 rows, smooth, no jump-cut at either end). Each row's brightness contribution is a soft Gaussian weight against the sweep's current center (SWEEP_SIGMA_ROWS=1.4, so 2-3 rows share a gentle gradient, never a 1-row strip) scaled by a low-amplitude SCAN_HIGHLIGHT_ALPHA=0.055 — luminance-only (never accent), added onto an already-lit LED's alpha for lit columns, and painted as a fainter version of the same gradient across only the off/background columns of that row (never stacking on top of an already-boosted dot) so the sweep reads independent of how much of the meter is filled. LIGHT THEME: the lowest PWM band (1 of 8, alpha 1/8 = 0.125) is floored to a MIN_ON_ALPHA (0.16) so it never rounds to invisible against a near-white --background. TOKENS: bg/fg are read once from getComputedStyle(document.documentElement) against --background/--foreground before the first paint, re-derived on a documentElement class MutationObserver so a theme flip is live; --ns-accent never appears anywhere in the LED brightness/duty-cycle mapping (luminance only, per spec) — there is no pointer-scrub affordance in this build, so accent is never reached for at all. A ResizeObserver on the canvas reflows on container size changes; an IntersectionObserver pauses the rAF loop off-screen and resumes it (fresh last-timestamp, no giant delta-time jump) on re-entry. RESTING LOOP: t0/2.5s/5s differ on two independent axes compounding — the sweep's slow triangle-wave position (always cycling, real clock) and, in uncontrolled mode, the slowly drifting simulated value — so the lit pattern, the PWM boundary step, and the sweep's gradient position are all visibly different at every timestamp, unbounded, zero input, and calm rather than flickering. prefers-reduced-motion freezes with the sweep locked off entirely (no gradient overlay drawn at all, every row rendered identically at its true duty level) at a value taken past the field's cold-start instant (uncontrolled mode) or the supplied static value (controlled mode) — the fully legible static readout with the PWM quantization bands still visible, no sweep artifact. The render loop pauses on document.hidden and resumes cleanly on visibilitychange. Direct-DOM rAF, zero React state on the hot path (value is read through a ref so a controlled prop updates without re-subscribing the effect), zero dependencies. A11Y: role=meter with aria-valuemin/aria-valuemax fixed from props and aria-valuenow written imperatively to the wrapper on every draw (cheap DOM attribute write, no aria-live spam) so assistive tech querying the node gets the true current reading whether it's externally controlled or internally simulated; the canvas itself is aria-hidden decoration. There is no keyboard surface because there is nothing to operate — non-interactive by design, correctly exempt from the registry's tab-reachability check as a display-only meter. Props: value (0..max, omit for the internal simulated sensor read), max (domain ceiling, default 100), label (accessible name, default 'Level'), height (panel height px, default 60; ROWS is fixed at 5, cell size derives as height / 5), className.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value? | number | — | current level, 0..max. Omit to drive the meter from an internal simulated sensor read. |
| max? | number | 100 | domain ceiling for value. Default 100. |
| label? | string | "Level" | accessible name for the reading, e.g. "CPU load". Default "Level". |
| height? | number | 60 | panel height in px; ROWS=5 fixed, cell size derives as height / 5. Default 60. |
| className? | string | — | extra classes merged onto the rendered root element |