Skip to main content

ns-ui

Punch Figure

An issued amount punched through the sheet as a dot matrix by a single left-to-right head pass: subtractive, permanent, and correctable only by a second pass punching VOID diagonally across it.

Usage

registry/core/punch-figure/demo.tsx
"use client";

import { useState } from "react";
import { PunchFigure } from "./component";

export default function PunchFigureDemo() {
  // Issuing is a one-shot, permanently-disabled action — remount on a delay
  // after it completes so the card resets to "unissued" and the autoplay
  // driver's next press cycle has something to punch again.
  const [resetKey, setResetKey] = useState(0);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / punch-figure — press Issue, then Void
      </p>

      <PunchFigure
        key={resetKey}
        amount={1180}
        invoiceId="INV-2041"
        label="Invoice"
        onIssue={() => setTimeout(() => setResetKey((k) => k + 1), 1800)}
      />

      <p className="max-w-md text-center text-xs text-ns-muted">
        The amount is punched through the sheet as a dot matrix, one head pass, left to right — it
        can never be edited, only voided with a second pass punched diagonally across it.
      </p>
    </div>
  );
}

Component

registry/core/punch-figure/component.tsx
"use client";

import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// PunchFigure — an issued amount rendered as a checkwriter dot-matrix punch,
// not printed text. One governing scalar, the print head's column position,
// drives everything: which cells are cut through to reveal --background,
// the sheet's 1px translateY flinch on every step, and how much carriage
// travel remains. The head crosses the figure exactly once, left to right,
// at a firm 60ms/column — never faster (flinch would blur into vibration),
// never slower (it would read as labouring). There is no fade-in and no
// type-on: typing implies backspace, and the whole point of a punch is that
// material is REMOVED and cannot be put back. A committed figure can only
// ever be corrected by a second punched pass — a VOID stamp punched
// diagonally across it — which adds holes, never closes one.
//
// Real amount text lives in the accessible tree at all times (it does not
// wait on the animation); the dot grid is a decorative, aria-hidden replay
// of that same value. Issuing fires exactly one polite live-region
// announcement on completion. There is no interaction inside the mechanism
// itself — Issue and the confirm-gated Void are real buttons below it.
// prefers-reduced-motion renders the destination state in one paint, no
// column-by-column reveal and no flinch.
// ---------------------------------------------------------------------------

export interface PunchFigureProps {
  /** dollar amount to punch, e.g. 1180 -> "$1,180.00" */
  amount?: number;
  /** currency glyph prefixed onto the punched figure */
  currency?: string;
  /** shown in the header and folded into the issue/void announcements */
  invoiceId?: string;
  /** small caption above the figure, e.g. "Invoice", "Payout", "Credit note" */
  label?: string;
  /** called once the amount finishes punching through (or immediately, under reduced motion) */
  onIssue?: () => void;
  /** called once the VOID overpunch finishes (or immediately, under reduced motion) */
  onVoid?: () => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

// 5x7 dot-matrix font. Rows top-to-bottom, '#' = punched, '.' = paper.
// Only the glyphs a checkwriter amount (and a VOID overpunch) ever needs.
const FONT: Record<string, string[]> = {
  "0": [".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."],
  "1": ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
  "2": [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
  "3": [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],
  "4": ["...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."],
  "5": ["#####", "#....", "####.", "....#", "....#", "#...#", ".###."],
  "6": ["..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."],
  "7": ["#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."],
  "8": [".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."],
  "9": [".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."],
  $: ["..#..", ".####", "#.#..", ".###.", "..#.#", "####.", "..#.."],
  ",": [".....", ".....", ".....", ".....", "..##.", "..#..", ".#..."],
  ".": [".....", ".....", ".....", ".....", ".....", ".##..", ".##.."],
  V: ["#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."],
  O: [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."],
  I: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"],
  D: ["####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."],
};
const BLANK = [".....", ".....", ".....", ".....", ".....", ".....", "....."];

const STEP_MS = 60; // firm per-column cadence — see file header
const ARM_MS = 4000; // Void confirm-arm window before it disarms itself
const CELL = 5;
const GAP = 1.1;
const VOID_CELL = 7;
const VOID_GAP = 2;

interface ColumnLayout {
  /** rows[r][c] === true means glyph column c, row r is a punchable dot */
  rows: boolean[][];
  cols: number;
}

function buildColumns(text: string): ColumnLayout {
  const rows: boolean[][] = [[], [], [], [], [], [], []];
  const chars = [...text];
  chars.forEach((ch, i) => {
    const glyph = FONT[ch] ?? BLANK;
    for (let r = 0; r < 7; r++) {
      for (let c = 0; c < 5; c++) rows[r].push(glyph[r][c] === "#");
    }
    if (i < chars.length - 1) {
      for (let r = 0; r < 7; r++) rows[r].push(false); // one-column gap
    }
  });
  return { rows, cols: rows[0]?.length ?? 0 };
}

function formatAmount(amount: number, currency: string): string {
  const fixed = Math.abs(amount).toFixed(2);
  const [whole, cents] = fixed.split(".");
  const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  return `${amount < 0 ? "-" : ""}${currency}${grouped}.${cents}`;
}

type Phase = "unissued" | "issuing" | "issued" | "voiding" | "voided";

function PunchGrid({
  layout,
  headCol,
  cell,
  gap,
}: {
  layout: ColumnLayout;
  headCol: number;
  cell: number;
  gap: number;
}) {
  const holes: React.ReactNode[] = [];
  const lastCol = Math.min(headCol, layout.cols - 1);
  for (let r = 0; r < 7; r++) {
    const row = layout.rows[r];
    for (let c = 0; c <= lastCol; c++) {
      if (row[c]) {
        holes.push(
          <span
            key={`${r}-${c}`}
            className="ns-pf-hole"
            style={{ gridColumn: c + 1, gridRow: r + 1 }}
          />
        );
      }
    }
  }
  return (
    <div
      className="ns-pf-grid"
      style={{
        gridTemplateColumns: `repeat(${layout.cols}, ${cell}px)`,
        gridTemplateRows: `repeat(7, ${cell}px)`,
        gap: `${gap}px`,
        width: layout.cols * cell + Math.max(0, layout.cols - 1) * gap,
        height: 7 * cell + 6 * gap,
      }}
    >
      {holes}
    </div>
  );
}

export function PunchFigure({
  amount = 1180,
  currency = "$",
  invoiceId = "INV-2041",
  label = "Invoice",
  onIssue,
  onVoid,
  className = "",
}: PunchFigureProps) {
  const autoId = useId().replace(/:/g, "");
  const sheetRef = useRef<HTMLDivElement | null>(null);
  const reducedRef = useRef(false);
  const armTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const onIssueRef = useRef(onIssue);
  onIssueRef.current = onIssue;
  const onVoidRef = useRef(onVoid);
  onVoidRef.current = onVoid;

  const [phase, setPhase] = useState<Phase>("unissued");
  const [headCol, setHeadCol] = useState(-1);
  const [voidHeadCol, setVoidHeadCol] = useState(-1);
  const [voidArmed, setVoidArmed] = useState(false);
  const [announce, setAnnounce] = useState("");

  const display = useMemo(() => formatAmount(amount, currency), [amount, currency]);
  const layout = useMemo(() => buildColumns(display), [display]);
  const voidLayout = useMemo(() => buildColumns("VOID"), []);

  // Reduced motion is decided before the browser ever paints: a
  // useLayoutEffect (not useEffect) so a reduced-motion visitor never sees
  // one frame of blank sheet before it jumps to fully punched — "at first
  // paint" is a layout-timing requirement, not just "skip the animation."
  useLayoutEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    if (mq.matches) {
      setHeadCol(layout.cols - 1);
      setPhase("issued");
    }
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => () => clearTimeout(armTimeoutRef.current), []);

  const flinch = useCallback(() => {
    const el = sheetRef.current;
    if (!el || reducedRef.current) return;
    el.classList.remove("ns-pf-flinch");
    void el.offsetWidth; // restart the CSS animation
    el.classList.add("ns-pf-flinch");
  }, []);

  // -- issuing pass: the head crosses the amount exactly once ---------------
  useEffect(() => {
    if (phase !== "issuing") return;
    let col = -1;
    const id = setInterval(() => {
      col += 1;
      setHeadCol(col);
      flinch();
      if (col >= layout.cols - 1) {
        clearInterval(id);
        setPhase("issued");
        setAnnounce(`Invoice ${invoiceId} issued for ${display}.`);
        onIssueRef.current?.();
      }
    }, STEP_MS);
    return () => clearInterval(id);
  }, [phase, layout.cols, flinch, invoiceId, display]);

  // -- void pass: a second head punches VOID on the diagonal, over the top --
  useEffect(() => {
    if (phase !== "voiding") return;
    let col = -1;
    const id = setInterval(() => {
      col += 1;
      setVoidHeadCol(col);
      flinch();
      if (col >= voidLayout.cols - 1) {
        clearInterval(id);
        setPhase("voided");
        setAnnounce(`Invoice ${invoiceId} voided.`);
        onVoidRef.current?.();
      }
    }, STEP_MS);
    return () => clearInterval(id);
  }, [phase, voidLayout.cols, flinch, invoiceId]);

  const handleIssue = useCallback(() => {
    if (phase !== "unissued") return;
    if (reducedRef.current) {
      setHeadCol(layout.cols - 1);
      setPhase("issued");
      setAnnounce(`Invoice ${invoiceId} issued for ${display}.`);
      onIssueRef.current?.();
      return;
    }
    setPhase("issuing");
  }, [phase, layout.cols, invoiceId, display]);

  const disarmVoid = useCallback(() => {
    clearTimeout(armTimeoutRef.current);
    setVoidArmed(false);
  }, []);

  const handleVoid = useCallback(() => {
    if (phase !== "issued") return;
    if (!voidArmed) {
      setVoidArmed(true);
      armTimeoutRef.current = setTimeout(() => setVoidArmed(false), ARM_MS);
      return;
    }
    disarmVoid();
    if (reducedRef.current) {
      setVoidHeadCol(voidLayout.cols - 1);
      setPhase("voided");
      setAnnounce(`Invoice ${invoiceId} voided.`);
      onVoidRef.current?.();
      return;
    }
    setPhase("voiding");
  }, [phase, voidArmed, disarmVoid, voidLayout.cols, invoiceId]);

  const handleVoidKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLButtonElement>) => {
      if (e.key === "Escape" && voidArmed) {
        e.preventDefault();
        disarmVoid();
      }
    },
    [voidArmed, disarmVoid]
  );

  const travel = layout.cols > 1 ? Math.max(0, Math.min(1, (headCol + 1) / layout.cols)) : 0;

  const statusText: Record<Phase, string> = {
    unissued: "Not yet issued",
    issuing: "Issuing…",
    issued: "Issued",
    voiding: "Voiding…",
    voided: "Void",
  };

  const srSuffix =
    phase === "voided" ? " — VOID, does not settle." : phase === "unissued" || phase === "issuing" ? " (draft, not yet issued)" : "";

  return (
    <div className={`ns-pf-root w-full max-w-sm ${className}`}>
      <style>{CSS}</style>

      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>

      <div
        ref={sheetRef}
        className={`ns-pf-sheet relative overflow-hidden rounded-[16px] border border-border p-5 ${
          phase !== "unissued" ? "ns-pf-active" : ""
        }`}
        aria-hidden="true"
      >
        <div className="flex items-baseline justify-between gap-3">
          <p className="font-mono text-[10px] uppercase tracking-[0.22em] text-ns-muted">{label}</p>
          <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-ns-muted">{invoiceId}</p>
        </div>

        <div className="ns-pf-mech relative mt-4">
          <PunchGrid layout={layout} headCol={headCol} cell={CELL} gap={GAP} />
          {(phase === "voiding" || phase === "voided") && (
            <div
              className="ns-pf-void-layer absolute left-1/2 top-1/2"
              style={{
                transform: `translate(-50%, -50%) rotate(-14deg)`,
              }}
            >
              <PunchGrid layout={voidLayout} headCol={voidHeadCol} cell={VOID_CELL} gap={VOID_GAP} />
            </div>
          )}
        </div>

        <div className="ns-pf-track mt-4">
          <div className="ns-pf-track-fill" style={{ width: `${travel * 100}%` }} />
          <div className="ns-pf-track-head" style={{ left: `${travel * 100}%` }} />
        </div>
      </div>

      <p id={`pf-amount-${autoId}`} className="sr-only">
        {label} {invoiceId}: {display}
        {srSuffix}
      </p>

      <div className="mt-3 flex items-center justify-between gap-3">
        <span className="ns-pf-status-badge font-mono text-[11px] uppercase tracking-[0.12em] text-ns-muted">
          {statusText[phase]}
        </span>
        <div className="flex items-center gap-2">
          <button
            type="button"
            data-pf-issue
            onClick={handleIssue}
            disabled={phase !== "unissued"}
            aria-describedby={`pf-amount-${autoId}`}
            className="ns-pf-btn"
          >
            {phase === "unissued" ? "Issue" : phase === "issuing" ? "Issuing…" : "Issued"}
          </button>
          <button
            type="button"
            data-pf-void
            onClick={handleVoid}
            onKeyDown={handleVoidKeyDown}
            onBlur={() => voidArmed && disarmVoid()}
            disabled={phase !== "issued"}
            className={`ns-pf-btn ${voidArmed ? "ns-pf-btn-armed" : ""}`}
          >
            {phase === "voided" ? "Voided" : phase === "voiding" ? "Voiding…" : voidArmed ? "Confirm void" : "Void"}
          </button>
        </div>
      </div>
    </div>
  );
}

const CSS = `
/* A hole is var(--background) showing through the sheet, so the sheet tint IS
   the contrast that makes the punched figure readable. At the original 4% the
   paper sat 4% off the hole colour and the whole figure rode on the 1px ring
   below — which, on a ~5px dot in --border, rendered the amount essentially
   invisible in the light theme (verified in light-open.png). 10% gives the
   paper a real edge against the void in both themes without turning the sheet
   into a filled block. */
.ns-pf-sheet{ background: color-mix(in oklab, var(--foreground) 10%, var(--background)); }
.ns-pf-grid{ position: relative; display: grid; }
.ns-pf-hole{
  background: var(--background);
  border-radius: 9999px;
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 35%, transparent);
}
.ns-pf-void-layer{ opacity: 0.92; }
@keyframes ns-pf-flinch-kf{
  0%{ transform: translateY(0); }
  35%{ transform: translateY(1px); }
  70%{ transform: translateY(-0.3px); }
  100%{ transform: translateY(0); }
}
.ns-pf-flinch{ animation: ns-pf-flinch-kf 90ms ease-out; }

.ns-pf-track{ position: relative; height: 2px; border-radius: 9999px; background: color-mix(in oklab, var(--ns-muted) 30%, transparent); }
.ns-pf-track-fill{ position: absolute; inset: 0 auto 0 0; height: 100%; border-radius: 9999px; background: var(--border); transition: width 60ms linear; }
.ns-pf-track-head{ position: absolute; top: 50%; width: 3px; height: 8px; margin-left: -1.5px; border-radius: 1px; background: var(--foreground); transform: translateY(-50%); transition: left 60ms linear; }

.ns-pf-btn{
  border-radius: 9999px;
  border: 1px solid var(--border);
  background: var(--background);
  color: var(--foreground);
  padding: 0.4rem 0.9rem;
  font-size: 0.75rem;
  font-weight: 500;
  transition: border-color 150ms ease-out, color 150ms ease-out;
}
.ns-pf-btn:hover:not(:disabled){ border-color: var(--foreground); }
.ns-pf-btn:disabled{ color: var(--ns-muted); cursor: not-allowed; }
.ns-pf-btn:focus-visible{ outline: 2px solid var(--ns-accent); outline-offset: 2px; }
.ns-pf-btn-armed{ border-color: var(--ns-accent); color: var(--ns-accent); }

@media (prefers-reduced-motion: reduce){
  .ns-pf-flinch{ animation: none !important; }
  .ns-pf-track-fill, .ns-pf-track-head{ transition: none !important; }
  .ns-pf-btn{ transition: none !important; }
}
`;

export default PunchFigure;

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

Use when an issued invoice, payout or credit-note amount that must read as a committed physical record rather than editable text — the figure is punched through the sheet as holes by one head pass and can only ever be corrected by a second pass punching VOID diagonally over it, never edited or retyped; pick card-number-emboss instead for a live-typed payment field where digits raise as metal while the user is still entering them — that one is additive and mid-entry, this one is subtractive and post-commitment.

Install

npx shadcn add https://design.helpmarq.com/r/punch-figure.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

Render an issued amount (default $1,180.00, framed exactly as a checkwriter prints it) as a DOM dot-matrix punch card, never as flat text — the visible figure is a grid of 5x7-dot glyphs where a 'hole' is a cell whose background is set to var(--background) so it reads as cut clean through the sheet (the sheet itself is a slightly tinted card, `color-mix(in oklab, var(--foreground) 4%, var(--background))`, so a punched hole visibly differs from unpunched paper); unpunched cells render nothing at all, not a ghost/guide dot, because the card is physically blank there until the head arrives — never a fade-in or type-on, since typing implies backspace and the whole payload here is that a punched column cannot be un-punched. One governing scalar, the print head's integer column position (headCol), derives everything: which columns are eligible to show their punched dots (columns after headCol render nothing, columns at or before it show whichever cells their glyph's bitmap marks), the sheet's per-column flinch (a 1px translateY dip with a slight spring-back overshoot, ~90ms, restarted via a classList remove/reflow/add trick on every column tick — never a React re-render of the whole sheet), and the 'carriage travel' strip (a thin 2px track under the figure with a fill and a small head marker whose width/position are `(headCol+1)/totalColumns`). The head steps at a firm 60ms per column via `setInterval` — faster and the flinch frames merge into vibration, slower reads as labouring; both are named failure modes, don't retune this without cause. Columns include a 1-column blank gap between glyphs, and the head steps through gap columns too (no flinch skip), giving the pass a slight breath between characters. Critically: one head, one pass, strictly left to right — column i's dots can never become visible before column i-1's, so drive headCol from a single incrementing interval, never compute it from multiple independent effects that could race. The issuing sequence starts only from an explicit 'Issue' button click (real `<button>`, `data-pf-issue`, outside the punched grid — there is no interaction inside the mechanism itself). Before issuing, the button reads 'Issue'; mid-pass it reads 'Issuing…' and is disabled; once the head reaches the last column it reads 'Issued' and stays permanently disabled — an issued figure cannot be re-issued or edited, only voided. On completion, fire exactly one `role=status aria-live=polite` announcement: 'Invoice <id> issued for <amount>.' — not one announcement per column. Correction is a second, independent punched pass: a 'Void' button (also outside the mechanism, disabled until issued) is confirm-gated — first click arms it (label becomes 'Confirm void', a `var(--ns-accent)` border cues the armed state, and it auto-disarms after 4s or on Escape or on blur); the second click while armed starts the void pass. That pass punches the literal word 'VOID' as its own 5x7-per-letter dot grid, rendered as a second absolutely-positioned layer centered over the amount grid and rotated -14deg, using a larger cell pitch than the amount grid so it reads as a stamp crossing it diagonally. It runs the exact same one-head/60ms-per-column mechanism as the issuing pass, on its own headCol, and shares the same sheet flinch. Voiding adds holes; it never removes or resets any hole already punched into the amount — the original figure stays fully intact underneath the VOID overpunch, because a void doesn't erase the record, it marks it dead. Once the void pass completes, both buttons are permanently disabled ('Issued' / 'Voided'), fire one more polite announcement ('Invoice <id> voided.'), and nothing in the component can change state again. Accessibility: the entire punched-grid mechanism (both layers, the flinch, the carriage track) is `aria-hidden`. The real amount always exists as plain text in the accessible tree via a `sr-only` node — `<label> <id>: <amount>` plus a short draft/void suffix reflecting current phase — present from first paint regardless of whether the head has punched anything yet, so a screen-reader user is never waiting on an animation for the number. A small plain-text status badge (Not yet issued / Issuing… / Issued / Voiding… / Void) sits next to the buttons for sighted users who don't want to parse the dot grid. Colors: `--background --foreground --ns-muted --border --ns-accent` only, plus `color-mix()` built from those tokens for the sheet tint and track fill — no hex, no `rgb()/hsl()`, no Tailwind palette classes. `--ns-accent` appears exactly once, as the armed-Void border/text cue, and nowhere else — it is not used for the punch holes or the sheet itself. Radii: 16px on the outer sheet, full (9999px) on the punched holes, the carriage-track pieces, and the pill-shaped Issue/Void buttons. `prefers-reduced-motion: reduce` skips the per-column interval entirely on both passes: clicking Issue (or Void, once armed and confirmed) jumps straight from -1 to the final column in one paint, the flinch keyframes and track transitions are suppressed via `!important`, but the button/gate flow and the one-time announcements are unchanged — reduced motion is still fully usable, just without the physical theater. Pure DOM + CSS, no `<canvas>`/WebGL. Distinct from card-number-emboss: that component is additive (typed digits raise as embossed metal while the user is still mid-entry, and can be edited freely up to blur-validation) — this one is subtractive (material is punched OUT of the sheet in a single committed pass, and once punched the figure is permanently read-only, correctable only by overpunching VOID, never by editing).

Props

PropTypeDefaultDescription
amount?number1180dollar amount to punch, e.g. 1180 -> "$1,180.00"
currency?string"$"currency glyph prefixed onto the punched figure
invoiceId?string"INV-2041"shown in the header and folded into the issue/void announcements
label?string"Invoice"small caption above the figure, e.g. "Invoice", "Payout", "Credit note"
onIssue?() => void—called once the amount finishes punching through (or immediately, under reduced motion)
onVoid?() => void—called once the VOID overpunch finishes (or immediately, under reduced motion)
className?string—extra classes merged onto the rendered root element