Skip to main content

ns-ui

Crossfoot Gap

An editable invoice grid that validates itself the way a bookkeeper crossfoots a ledger: row totals right of a vertical rule, column totals under a horizontal one, and a corner gap whose width (not a warning icon) carries the size of the mismatch.

Usage

registry/core/crossfoot-gap/demo.tsx
"use client";

import { useState } from "react";
import { CrossfootGap, type CrossfootColumn, type CrossfootRow } from "./component";

const COLUMNS: CrossfootColumn[] = [
  { id: "lodging", label: "Lodging" },
  { id: "meals", label: "Meals" },
  { id: "transport", label: "Transport" },
];

const ROWS: CrossfootRow[] = [
  { id: "hotel", label: "Grand Hotel — 3 nights", amount: 612.0 },
  { id: "taxi", label: "Downtown Taxi Co", amount: 84.5 },
  { id: "cafe", label: "Café Luz — working lunch", amount: 38.25 },
  { id: "parking", label: "Airport parking", amount: 96.0 },
];

// Every receipt fully coded — the ledger foots exactly, at rest, with no
// submit step: it is simply what's true of this data right now.
const BALANCED_CELLS: Record<string, Record<string, string>> = {
  hotel: { lodging: "550.00", meals: "42.00", transport: "20.00" },
  taxi: { transport: "84.50" },
  cafe: { meals: "38.25" },
  parking: { transport: "96.00" },
};

export default function CrossfootGapDemo() {
  const [cells, setCells] = useState(BALANCED_CELLS);

  function handleCellChange(rowId: string, colId: string, value: string) {
    setCells((prev) => ({
      ...prev,
      [rowId]: { ...prev[rowId], [colId]: value },
    }));
  }

  function simulateTypo() {
    // A fat-fingered digit — nine cents off.
    setCells((prev) => ({ ...prev, hotel: { ...prev.hotel, lodging: "550.09" } }));
  }

  function simulateDroppedLine() {
    // The whole hotel receipt never got coded — off by exactly its amount.
    setCells((prev) => ({ ...prev, hotel: {} }));
  }

  function reset() {
    setCells(BALANCED_CELLS);
  }

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-16">
      <div className="w-full max-w-2xl">
        <p className="mb-3 font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / crossfoot-gap — expense report</p>

        <CrossfootGap
          columns={COLUMNS}
          rows={ROWS}
          cells={cells}
          onCellChange={handleCellChange}
          ariaLabel="Q3 travel expense coding"
        />

        <div className="mt-5 flex flex-wrap gap-2">
          <button
            type="button"
            onClick={simulateTypo}
            className="rounded-md border border-border px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:bg-foreground/[0.06] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            Simulate typo (9¢)
          </button>
          <button
            type="button"
            onClick={simulateDroppedLine}
            className="rounded-md border border-border px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:bg-foreground/[0.06] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            Simulate dropped line ($612)
          </button>
          <button
            type="button"
            onClick={reset}
            className="rounded-md bg-ns-accent px-3 py-1.5 font-mono text-xs text-white transition-colors hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            Reset — close the ledger
          </button>
        </div>
      </div>
    </div>
  );
}

Component

registry/core/crossfoot-gap/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// CrossfootGap — an invoice/allocation grid that validates itself the way a
// bookkeeper crossfoots a paper ledger: foot every row, foot every column,
// and check the two grand totals meet in the corner. Each ROW carries a
// stated amount (the ground truth — a receipt total, an invoice line, a
// budget cap) that is NOT itself computed from the grid; each interior cell
// is where the user TYPES how that amount splits across columns (cost
// codes, periods, categories). Because the stated amounts and the typed
// splits are two independently-sourced numbers, they can genuinely
// disagree — unlike a plain matrix where row-sums and column-sums of the
// SAME cells are always identical by construction, this is a real
// reconciliation: forget to code part of a receipt and the grid falls
// short by exactly that amount; mistype a digit and it's off by pennies.
//
// MECHANISM: one function (computeLedger, below) derives { rowStated,
// colTotals, sumStated, sumAllocated, diffCents } from props in a single
// pass — the same object feeds both the printed row/column/grand totals AND
// the gap's pixel width, so there is no second calculation that could drift
// out of sync with what's on screen. gapPx = 0 when diffCents === 0,
// otherwise clamp(3, log1p(|$|)/log1p(10000) * 24, 24) — a floor so a
// one-cent miss is never rendered as "closed", a log curve so a $0.09 typo
// and a $1,240 dropped line read as visibly different widths, and a 24px
// ceiling so the gap can never push the table into reflow.
//
// GEOMETRY: a single <svg> overlays the table (position:relative wrapper).
// The horizontal closing rule runs from the table's left edge to
// cornerX − gap/2 at y = cornerY (the top of the footer row); the vertical
// closing rule runs from the table's top edge to cornerY − gap/2 at
// x = cornerX (the left edge of the row-totals column). cornerX/cornerY are
// measured directly off the grand-total <td> via getBoundingClientRect, so
// the rules always terminate exactly at its corner regardless of column
// widths. The signed diff prints in Geist Mono, decorative and aria-hidden,
// centered on the corner and rotated 45° — along the bisector of the
// opening between a rule receding left and one receding up. Gap width eases
// toward its target via a small rAF tween (ease-out-cubic, ~240ms) rather
// than a CSS transition on raw SVG geometry, so it holds up everywhere.
// At diffCents === 0 a second pair of hairlines — the accountant's double
// rule — inks in under the grand-total figure via a pathLength=1
// stroke-dashoffset keyframe (350ms, ease-out-expo), keyed so it replays
// every time the ledger newly closes, not just once.
//
// A11Y: a real <table>; every interior <input> carries its own aria-label
// ("<row> allocated to <column>"); row/column/grand totals are read-only
// <td>s with tabIndex=0 so they're reachable but never mistaken for
// editable fields; the grand-total cell's own visible text states the
// reconciled status in words ("Ledger closed" / "off by −$0.09") — the
// SVG's rotated echo is decoration, not the only channel. A debounced
// aria-live=polite region mirrors the same status ("out of balance by
// −$12.40" / "Ledger closed") without announcing every keystroke. No
// submit step: every number is live off controlled state. Reduced motion
// removes the tween (gap width jumps straight to target) and the ink-in
// keyframe (double rule is simply present, fully drawn).
// ---------------------------------------------------------------------------

export interface CrossfootColumn {
  id: string;
  /** column header, e.g. a cost code or period */
  label: string;
}

export interface CrossfootRow {
  id: string;
  /** row header, e.g. a receipt or line-item description */
  label: string;
  /** the stated / ground-truth amount for this line, in dollars */
  amount: number;
}

export interface CrossfootGapProps {
  columns: CrossfootColumn[];
  rows: CrossfootRow[];
  /** controlled split: cells[rowId]?.[colId] = the raw string currently typed into that cell */
  cells: Record<string, Record<string, string>>;
  onCellChange: (rowId: string, colId: string, value: string) => void;
  /** currency symbol prefix for every printed amount. default "$" */
  currency?: string;
  /** accessible name for the table. default "Invoice ledger" */
  ariaLabel?: string;
  /** row-header column heading. default "Line item" */
  rowHeaderLabel?: string;
  /** footer row heading, above the computed column totals. default "Allocated" */
  footerLabel?: string;
  className?: string;
}

const TWEEN_MS = 240;
const WIPE_MS = 350;
const LIVE_DEBOUNCE_MS = 500;
const MIN_GAP_PX = 3;
const MAX_GAP_PX = 24;
const GAP_SATURATION_DOLLARS = 10000;

function toCents(raw: string | undefined): number {
  if (!raw) return 0;
  const cleaned = raw.replace(/[^0-9.-]/g, "");
  if (!cleaned) return 0;
  const n = Number.parseFloat(cleaned);
  if (!Number.isFinite(n)) return 0;
  return Math.round(n * 100);
}

function formatCents(cents: number, currency: string): string {
  // A negative allocation is legitimate bookkeeping (a credit line), so the
  // input regex in toCents keeps '-' anywhere, not just leading. But
  // splitting sign off the FLOORED/MODULO'd value, rather than the raw
  // magnitude, put the sign on the dollars term and left it on the cents
  // remainder too (JS '%' preserves the dividend's sign): -55025 cents
  // rendered as "$-551.-25" (floor(-550.25) = -551, -55025 % 100 = -25)
  // instead of "-$550.25". Take the sign off the magnitude once, up front,
  // then floor/mod the absolute value so dollars and cents never carry a
  // second, independent sign.
  const sign = cents < 0 ? "−" : "";
  const abs = Math.abs(cents);
  const dollars = Math.floor(abs / 100);
  const rem = abs % 100;
  return `${sign}${currency}${dollars.toLocaleString("en-US")}.${String(rem).padStart(2, "0")}`;
}

function formatSigned(diffCents: number, currency: string): string {
  const sign = diffCents < 0 ? "−" : "+";
  return `${sign}${formatCents(Math.abs(diffCents), currency)}`;
}

function gapPxFromDiff(diffCents: number): number {
  if (diffCents === 0) return 0;
  const dollars = Math.abs(diffCents) / 100;
  const t = Math.log1p(dollars) / Math.log1p(GAP_SATURATION_DOLLARS);
  const px = MIN_GAP_PX + t * (MAX_GAP_PX - MIN_GAP_PX);
  return Math.min(MAX_GAP_PX, Math.max(MIN_GAP_PX, px));
}

function computeLedger(rows: CrossfootRow[], columns: CrossfootColumn[], cells: CrossfootGapProps["cells"]) {
  const colTotals = columns.map((col) =>
    rows.reduce((sum, row) => sum + toCents(cells[row.id]?.[col.id]), 0)
  );
  const rowStated = rows.map((row) => Math.round(row.amount * 100));
  const sumStated = rowStated.reduce((a, b) => a + b, 0);
  const sumAllocated = colTotals.reduce((a, b) => a + b, 0);
  const diffCents = sumAllocated - sumStated;
  return { colTotals, rowStated, sumStated, sumAllocated, diffCents, gapPx: gapPxFromDiff(diffCents) };
}

export function CrossfootGap({
  columns,
  rows,
  cells,
  onCellChange,
  currency = "$",
  ariaLabel = "Invoice ledger",
  rowHeaderLabel = "Line item",
  footerLabel = "Allocated",
  className = "",
}: CrossfootGapProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const cornerRef = useRef<HTMLTableCellElement>(null);
  const headlineRef = useRef<HTMLSpanElement>(null);

  const [reducedMotion, setReducedMotion] = useState(false);
  const [size, setSize] = useState({ w: 0, h: 0 });
  const [corner, setCorner] = useState({ x: 0, y: 0, w: 0, h: 0 });
  const [headline, setHeadline] = useState({ y: 0 });
  const [gapAnim, setGapAnim] = useState(0);
  const [closedKey, setClosedKey] = useState(0);
  const [liveMsg, setLiveMsg] = useState("");

  const gapAnimRef = useRef(0);
  const prevDiffRef = useRef<number | null>(null);
  const liveTimerRef = useRef<number | undefined>(undefined);

  const ledger = useMemo(() => computeLedger(rows, columns, cells), [rows, columns, cells]);
  const { colTotals, rowStated, sumStated, diffCents, gapPx } = ledger;

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReducedMotion(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  // Measure the grand-total corner (and the wrap it's positioned against)
  // straight off the DOM so the closing rules terminate exactly at its
  // edges no matter how column widths land.
  useLayoutEffect(() => {
    function measure() {
      const wrap = wrapRef.current;
      const cornerEl = cornerRef.current;
      if (!wrap || !cornerEl) return;
      const wrapRect = wrap.getBoundingClientRect();
      const cRect = cornerEl.getBoundingClientRect();
      setSize({ w: wrap.scrollWidth, h: wrap.scrollHeight });
      setCorner({
        x: cRect.left - wrapRect.left + wrap.scrollLeft,
        y: cRect.top - wrapRect.top + wrap.scrollTop,
        w: cRect.width,
        h: cRect.height,
      });
      const hEl = headlineRef.current;
      if (hEl) {
        const hRect = hEl.getBoundingClientRect();
        setHeadline({ y: hRect.bottom - wrapRect.top + wrap.scrollTop });
      }
    }
    measure();
    const ro = new ResizeObserver(measure);
    if (wrapRef.current) ro.observe(wrapRef.current);
    return () => ro.disconnect();
  }, [rows, columns, cells]);

  // Ease the gap width toward its target instead of snapping — a small
  // hand-rolled tween rather than a CSS transition on raw SVG attributes,
  // which does not animate reliably everywhere.
  useEffect(() => {
    if (reducedMotion) {
      setGapAnim(gapPx);
      gapAnimRef.current = gapPx;
      return;
    }
    let raf = 0;
    const from = gapAnimRef.current;
    const to = gapPx;
    const start = performance.now();
    function tick(now: number) {
      const t = Math.min(1, (now - start) / TWEEN_MS);
      const eased = 1 - (1 - t) ** 3;
      const val = from + (to - from) * eased;
      gapAnimRef.current = val;
      setGapAnim(val);
      if (t < 1) raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [gapPx, reducedMotion]);

  // Replay the double-rule ink-in every time the ledger newly closes, not
  // just on the first render it happens to already be closed.
  useEffect(() => {
    if (prevDiffRef.current !== null && prevDiffRef.current !== 0 && diffCents === 0) {
      setClosedKey((k) => k + 1);
    }
    prevDiffRef.current = diffCents;
  }, [diffCents]);

  // Debounce the live-region announcement so typing doesn't narrate every
  // keystroke — the visual gap already updates immediately.
  useEffect(() => {
    if (liveTimerRef.current) window.clearTimeout(liveTimerRef.current);
    liveTimerRef.current = window.setTimeout(() => {
      setLiveMsg(diffCents === 0 ? "Ledger closed" : `out of balance by ${formatSigned(diffCents, currency)}`);
    }, LIVE_DEBOUNCE_MS);
    return () => {
      if (liveTimerRef.current) window.clearTimeout(liveTimerRef.current);
    };
  }, [diffCents, currency]);

  const closed = diffCents === 0;
  const cornerLabel = closed
    ? `Grand total ${formatCents(sumStated, currency)} — Ledger closed`
    : `Grand total ${formatCents(sumStated, currency)} — out of balance by ${formatSigned(diffCents, currency)}`;

  const notchX = corner.x + gapAnim * 0.5;
  const notchY = corner.y + gapAnim * 0.5;

  return (
    <div className={["ns-cf", className].join(" ")}>
      <style>{`
.ns-cf-scroll{overflow-x:auto}
.ns-cf-wrap{position:relative}
.ns-cf table{border-collapse:collapse;width:100%}
.ns-cf th,.ns-cf td{white-space:nowrap}
.ns-cf thead th{font:12px/1.3 var(--font-mono, ui-monospace, monospace);text-transform:uppercase;letter-spacing:.08em;color:var(--ns-muted);text-align:right;padding:0 10px 8px;font-weight:500}
.ns-cf thead th:first-child,.ns-cf tbody th{text-align:left}
.ns-cf tbody th{font:13px/1.4 var(--font-mono, ui-monospace, monospace);color:var(--foreground);font-weight:400;padding:7px 10px 7px 2px;border-top:1px solid var(--border)}
.ns-cf tbody td{padding:5px 10px;border-top:1px solid var(--border);text-align:right}
.ns-cf tbody tr:first-child th,.ns-cf tbody tr:first-child td{border-top:none}
.ns-cf-input{width:6.4em;text-align:right;background:transparent;color:var(--foreground);font:13px/1.4 var(--font-mono, ui-monospace, monospace);border:1px solid transparent;border-radius:6px;padding:4px 6px;outline:none}
.ns-cf-input::placeholder{color:var(--ns-muted);opacity:.55}
.ns-cf-input:hover{border-color:var(--border)}
.ns-cf-input:focus-visible{border-color:transparent;outline:2px solid var(--ns-accent);outline-offset:1px}
.ns-cf-rowtotal{font:13px/1.4 var(--font-mono, ui-monospace, monospace);color:var(--ns-muted);padding:5px 2px 5px 10px}
.ns-cf-rowtotal:focus-visible{outline:2px solid var(--ns-accent);outline-offset:-1px;border-radius:4px}
.ns-cf-foot th{text-align:left;font:12px/1.3 var(--font-mono, ui-monospace, monospace);color:var(--ns-muted);padding:10px 10px 10px 2px;font-weight:400}
.ns-cf-foot td{font:13px/1.4 var(--font-mono, ui-monospace, monospace);color:var(--foreground);padding:10px;text-align:right}
.ns-cf-foot td:focus-visible,.ns-cf-foot th:focus-visible{outline:2px solid var(--ns-accent);outline-offset:-1px;border-radius:4px}
.ns-cf-corner{vertical-align:top;position:relative}
/* gap must clear the double rule, which the SVG overlay draws at
   headline.bottom + 4px and + 7px. At the original 2px the status caption
   started at +2 and the two rules struck straight through "Ledger closed".
   12px puts the caption 5px below the lower rule, so the corner reads as an
   accountant's block: figure, double underline, then the status word. */
.ns-cf-corner-inner{display:inline-flex;flex-direction:column;align-items:flex-end;gap:12px}
.ns-cf-corner-headline{font-size:14px;color:var(--foreground)}
.ns-cf-corner-status{font-size:11px;color:var(--ns-muted)}
.ns-cf-corner[data-closed="true"] .ns-cf-corner-status{color:var(--foreground)}
.ns-cf-rules{position:absolute;inset:0;pointer-events:none}
.ns-cf-rule{stroke:var(--border);stroke-width:1;transition:none}
.ns-cf-double-line{stroke:var(--foreground);stroke-width:1;fill:none;stroke-dasharray:1;stroke-dashoffset:0}
.ns-cf-double[data-anim="true"] .ns-cf-double-line{animation:ns-cf-wipe ${WIPE_MS}ms cubic-bezier(.16,1,.3,1) forwards}
@keyframes ns-cf-wipe{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
.ns-cf-notch{position:absolute;font:11px/1 var(--font-mono, ui-monospace, monospace);color:var(--foreground);transform:translate(-50%,-50%) rotate(45deg);white-space:nowrap;pointer-events:none}
@media (prefers-reduced-motion: reduce){
  .ns-cf-double-line{animation:none!important;stroke-dashoffset:0!important}
}
`}</style>

      <div className="ns-cf-scroll rounded-xl border border-border bg-background">
        <div ref={wrapRef} className="ns-cf-wrap px-3 py-3">
          <table aria-label={ariaLabel}>
            <thead>
              <tr>
                <th scope="col">{rowHeaderLabel}</th>
                {columns.map((col) => (
                  <th scope="col" key={col.id}>
                    {col.label}
                  </th>
                ))}
                <th scope="col">Total</th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row, ri) => (
                <tr key={row.id}>
                  <th scope="row">{row.label}</th>
                  {columns.map((col) => (
                    <td key={col.id}>
                      <input
                        type="text"
                        inputMode="decimal"
                        className="ns-cf-input"
                        placeholder="0.00"
                        aria-label={`${row.label} allocated to ${col.label}`}
                        data-cell={`${row.id}-${col.id}`}
                        value={cells[row.id]?.[col.id] ?? ""}
                        onChange={(e) => onCellChange(row.id, col.id, e.target.value)}
                      />
                    </td>
                  ))}
                  <td
                    className="ns-cf-rowtotal"
                    tabIndex={0}
                    aria-label={`${row.label} total: ${formatCents(rowStated[ri], currency)}`}
                  >
                    {formatCents(rowStated[ri], currency)}
                  </td>
                </tr>
              ))}
            </tbody>
            <tfoot>
              <tr className="ns-cf-foot">
                <th scope="row" tabIndex={0}>
                  {footerLabel}
                </th>
                {columns.map((col, ci) => (
                  <td key={col.id} tabIndex={0} aria-label={`${col.label} allocated total: ${formatCents(colTotals[ci], currency)}`}>
                    {formatCents(colTotals[ci], currency)}
                  </td>
                ))}
                <td
                  ref={cornerRef}
                  className="ns-cf-corner"
                  data-closed={closed}
                  tabIndex={0}
                  aria-label={cornerLabel}
                >
                  <span className="ns-cf-corner-inner">
                    <span ref={headlineRef} className="ns-cf-corner-headline">
                      {formatCents(sumStated, currency)}
                    </span>
                    <span className="ns-cf-corner-status">{closed ? "Ledger closed" : `off by ${formatSigned(diffCents, currency)}`}</span>
                  </span>
                </td>
              </tr>
            </tfoot>
          </table>

          <svg aria-hidden="true" className="ns-cf-rules" width={size.w} height={size.h}>
            <line className="ns-cf-rule" x1={0} y1={corner.y} x2={Math.max(0, corner.x - gapAnim / 2)} y2={corner.y} />
            <line className="ns-cf-rule" x1={corner.x} y1={0} x2={corner.x} y2={Math.max(0, corner.y - gapAnim / 2)} />
            {closed && corner.w > 0 ? (
              <g key={closedKey} className="ns-cf-double" data-anim="true">
                <line
                  className="ns-cf-double-line"
                  pathLength={1}
                  x1={corner.x + 4}
                  y1={headline.y + 4}
                  x2={corner.x + corner.w - 4}
                  y2={headline.y + 4}
                />
                <line
                  className="ns-cf-double-line"
                  pathLength={1}
                  x1={corner.x + 4}
                  y1={headline.y + 7}
                  x2={corner.x + corner.w - 4}
                  y2={headline.y + 7}
                />
              </g>
            ) : null}
          </svg>

          {!closed && corner.w > 0 ? (
            <div className="ns-cf-notch" style={{ left: notchX, top: notchY }}>
              {formatSigned(diffCents, currency)}
            </div>
          ) : null}
        </div>
      </div>

      <div aria-live="polite" className="sr-only">
        {liveMsg}
      </div>
    </div>
  );
}

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

Use when a line-item grid (invoice coding, expense receipts, budget allocation) where 'does this add up' should be answered by the geometry of a closing corner at rest, with the gap's width itself proportional to the size of the mismatch — not by validation-error-summary, which annotates individual fields with messages after a submit and carries no notion of a numeric-magnitude error at all.

Install

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

An invoice/allocation grid where each ROW carries a stated ground-truth amount (a receipt total, an invoice line) as a prop, and each interior cell is a native <input inputmode="decimal"> where the user types how that amount splits across the grid's COLUMNS (cost codes, categories, periods). Because the stated amounts and the typed splits are two independently-sourced numbers rather than one matrix summed two ways, they can genuinely disagree: leave part of a receipt uncoded and the ledger falls short by exactly that line's amount; mistype a digit and it's off by pennies. One function (computeLedger) derives { rowStated, colTotals, sumStated, sumAllocated, diffCents } in a single pass from props — that same object feeds both the printed row/column/grand totals AND the gap's pixel width, so there is no second calculation that could drift out of sync with what's on screen. GEOMETRY: rows sit in a real <table> (<th scope="row"> per line item, <th scope="col"> per category) with a read-only, tabIndex=0 row-total cell (the stated amount) at the right of each row and a read-only, tabIndex=0 column-total cell (the computed allocation) in a <tfoot> row; the grand-total <td> at their intersection is measured directly via getBoundingClientRect so an absolutely-positioned <svg> overlay can draw two --border hairlines that terminate exactly at its edges: a horizontal rule from the table's left edge to cornerX − gap/2, a vertical rule from the table's top edge to cornerY − gap/2. gapPx = 0 when diffCents === 0, otherwise clamp(3px floor, log1p(|dollars|)/log1p(10000) scaled to 24px, 24px ceiling) — the floor keeps a one-cent miss from rendering as visually closed, the log curve makes a $0.09 typo and a $1,240 dropped line read as distinguishably different widths, and the 24px cap means the gap can never push the table into reflow. The signed diff prints in Geist Mono, aria-hidden and decorative, centered on the corner and rotated 45° along the bisector of the opening (one rule receding left, the other up). Gap width eases toward its target with a small hand-rolled requestAnimationFrame tween (ease-out-cubic, ~240ms) rather than a CSS transition on raw SVG attributes. The instant diffCents reaches exactly zero, a second pair of hairlines — the accountant's double rule — inks in under the grand-total figure via a pathLength=1 stroke-dashoffset keyframe (350ms, ease-out-expo cubic-bezier(.16,1,.3,1)), re-keyed so it replays every time the ledger newly closes rather than only once. Corner cell text always states the reconciled status in words ("Ledger closed" / "off by −$0.09") independent of the SVG, and a debounced (500ms) aria-live="polite" region mirrors the same phrasing without narrating every keystroke — the visible gap itself updates immediately as you type. There is no submit step: every total, and the gap itself, is simply what is true of the current controlled state. The corner gap is the ONLY error indicator in the component — no per-cell warning icons, no boolean invalid flag anywhere. Props: columns ({id,label}[]), rows ({id,label,amount}[] — amount is the stated ground truth in dollars), cells (Record<rowId, Record<colId, string>>, controlled), onCellChange(rowId, colId, value), currency (default "$"), ariaLabel, rowHeaderLabel (default "Line item"), footerLabel (default "Allocated"), className. Reduced motion removes the rAF tween (gap width jumps straight to its target) and the ink-in keyframe (the double rule is simply present, fully drawn) — nothing becomes unreachable or unreadable, only motion is removed. Pure DOM + SVG + CSS, no canvas; every color is --background/--foreground/--ns-muted/--border/--ns-accent, --ns-accent used only on input focus rings. Demo: a four-line expense report (hotel receipt split across lodging/meals/transport, three single-category receipts) that foots exactly at rest, plus 'Simulate typo', 'Simulate dropped line' and 'Reset' buttons that put the two named failure magnitudes on tap.

Props

PropTypeDefaultDescription
columnsCrossfootColumn[]——
rowsCrossfootRow[]——
cellsRecord<string, Record<string, string>>—controlled split: cells[rowId]?.[colId] = the raw string currently typed into that cell
onCellChange(rowId: string, colId: string, value: string) => void——
currency?string"$"currency symbol prefix for every printed amount. default "$"
ariaLabel?string"Invoice ledger"accessible name for the table. default "Invoice ledger"
rowHeaderLabel?string"Line item"row-header column heading. default "Line item"
footerLabel?string"Allocated"footer row heading, above the computed column totals. default "Allocated"
className?string——