Skip to main content

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

Source
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

PropTypeDefaultDescription
value?numbercurrent level, 0..max. Omit to drive the meter from an internal simulated sensor read.
max?number100domain ceiling for value. Default 100.
label?string"Level"accessible name for the reading, e.g. "CPU load". Default "Level".
height?number60panel height in px; ROWS=5 fixed, cell size derives as height / 5. Default 60.
className?stringextra classes merged onto the rendered root element