Skip to content

PeriodGrid

The ruled grid. Frozen panes, hidden columns, hover change, ⌘C.
Implemented
Status
Implemented
Family
5 of 13
Registry
@lopifi/period-grid
npx shadcn@latest add @lopifi/theme @lopifi/period-grid

The four-layer spec

The ruled grid: rows down, periods across, and the six spreadsheet behaviours an accountant performs on a workbook within a minute of opening it. Every period-by-period surface in the system — ForecastTable, BudgetVsActual, RevenueTracker, ExpenseTracker, DriverModel, ScenarioCompare, PlByDimension — is a configuration of this one grid rather than another table written from scratch.

Ruled cells, and why the statements are not

A statement is read across one row at a time, and its horizontal rules carry meaning — single above a subtotal, double under a total. A period grid is read down a column as often as across a row, and a column with no left edge is one the eye loses the moment two neighbouring figures happen to be the same width. So the grid boxes every cell and the statements keep their accounting rules with a vertical seam added between period columns. Two different documents, two different rulings, one reason each.

border-collapse: separate is required rather than preferred: a sticky cell in a collapsed table loses its borders when it detaches, because the border belongs to the table and the cell scrolls away from it.

Freeze panes

Top row and left column, the two anyone freezes in every workbook they open. The freeze shadow appears only once something is hidden behind it — a permanent shadow on a grid that fits its container is a scroll cue for a scroll that cannot happen.

Hidden columns

Nobody reviews fourteen months at once. Hiding is not deleting: rows index into the full column list, so a hidden column never renumbers the data behind it, and a declared comparison (column.compare) still measures against its base while that base is off screen. The count of hidden columns is always shown with a one-click path back — a reader who hid six columns last quarter needs to know this grid is not the whole story.

The change, printed under the figure

The figure answers "how much"; the next question is always "versus what". That answer is printed under every figure at once — small, toned, right-aligned — rather than revealed one cell at a time on hover. The reason anyone opens this grid is to compare, and a comparison you can only make one cell at a time is not a comparison. The pointer and the keyboard cursor still fill the formula bar under the grid with the full sentence, including the amount and the column being measured against.

The sub-line is a percentage, not an amount: the amounts are already on screen directly above and to the left, and the percentage is the thing the eye cannot do for itself while scanning seven columns. On a percentage row it is points instead — calling a move from 51.8% to 53.0% "+2.3%" is the most common way a deck lies by accident.

No invented percentages. No ratio against a zero base, none against a negative base, and a missing figure stays missing. The sub-line says which of the three it was instead of printing a number that reads backwards, and the first column on screen shows an en dash rather than a zero.

Annotations, not views

Two independent toggles — % change, and % of a named base row — each adding a line under the figures. Neither replaces them. A segmented Value / Δ / Δ% switch forces the reader to hold one of the two numbers they are comparing in their head while they look at the other, and a grid whose numbers are swapped out for percentages under a tab press is a grid that has to be re-read every time it is touched.

Typing

Off by default: a grid over posted figures is a report, and a report that accepts typing is one someone will believe they have changed. Where a caller marks cells editable — the projected inputs of a driver model, never its actuals or its computed rows — a cell opens on double-click or Enter and accepts what a spreadsheet accepts, including 1.2k, (1,240) and =1200+340. A value it cannot parse commits nothing rather than committing zero.

Keyboard and clipboard

One tab stop, then arrow keys, PageUp/PageDown, Home/End, shift to extend a range and ⌘C to copy it. The clipboard carries data-raw values, so the paste sums in the spreadsheet it is going back to.

Lifted verbatim from Ledger & tables. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/period-grid.tsx

Lopifi dependencies · 12
clipboardcncolumn-managerformat-moneyiconsmoneymoney.typesperiod-changetooltipuse-column-widthsuse-grid-navuse-scroll-edges
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/period-grid.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/period-grid.tsx
"use client";

import * as React from "react";
import { useColumnWidths } from "@/hooks/ledger/use-column-widths";
import { useGridNav } from "@/hooks/ledger/use-grid-nav";
import { useScrollEdges } from "@/hooks/ledger/use-scroll-edges";
import { cn } from "@/lib/ledger/cn";
import { formatMoney, formatMoneyInput, formatPercent, parseMoneyInput, rawMoney } from "@/lib/ledger/format-money";
import type { Density, MoneyValue, Polarity } from "@/lib/ledger/money.types";
import { describeChange, type PeriodChange, periodChange, shareOfBase } from "@/lib/ledger/period-change";
import { ColumnManager, type ManagedColumn } from "@/components/ledger/column-manager";
import { Money } from "@/components/ledger/money";

/* ── The shapes ──────────────────────────────────────────────────────── */

export type GridUnit = "money" | "percent" | "points" | "count" | "ratio" | "days";

export type GridRowVariant = "line" | "subtotal" | "total" | "section" | "ratio";

export interface PeriodColumn {
  /**
   * The column this one is measured against, by id. Defaults to the previous
   * visible column, which is what a month-by-month grid wants.
   *
   * Naming it explicitly is what lets one grid be a budget-versus-actual: the
   * actual column compares to budget rather than to the month before it, and
   * the comparison survives the reader hiding the budget column — the
   * relationship is a property of the model, not of what is on screen.
   */
  compare?: string;
  /** Starts hidden. The reader can bring it back from the column menu. */
  hidden?: boolean;
  /** Extra context under the header, e.g. "board-approved 12 Feb". */
  hint?: string;
  id: string;
  /**
   * What the column holds. Drives the sub-label and the projected styling —
   * a forecast column that looks like an actual is the single most expensive
   * mistake this grid can make.
   */
  kind?: "actual" | "forecast" | "budget" | "prior" | "derived";
  /** "Oct", "Q3 FY26", "FY26 budget". Resolved, never "Current". */
  label: string;
  /** Cannot be hidden — a grid with no periods left is not a grid. */
  locked?: boolean;
  width?: number;
}

export interface PeriodRow {
  /** A computed row. Marked, so nobody reconciles it to a source ledger. */
  derived?: boolean;
  id: string;
  label: string;
  /** 1–5. Indents the label; deeper than 5 is a chart-of-accounts problem. */
  level?: number;
  note?: string;
  /** Required for any row whose movement will be read as good or bad news. */
  polarity?: Polarity;
  unit?: GridUnit;
  /**
   * One entry per column in `columns` — including hidden ones, so that hiding
   * a column never renumbers the data behind it. `null` is "we do not have
   * this figure". It is never 0.
   */
  values: (number | null)[];
  variant?: GridRowVariant;
}

/**
 * Everything known about one cell, read once and handed to the three places
 * that speak about it — the figure, its sub-line and the formula bar. Three
 * call sites computing the same change three times is how a sub-line ends up
 * disagreeing with the row it sits on.
 */
export interface PeriodCellRead {
  change: PeriodChange;
  column: PeriodColumn;
  /** The change cleared both materiality thresholds. */
  material: boolean;
  /**
   * Worth printing without being asked. A grid that annotates all ninety of
   * its cells equally has annotated none of them — the eye needs somewhere to
   * land. Everything quieter than this waits for the row to be pointed at.
   */
  notable: boolean;
  /** The column this cell's change is measured against, if there is one. */
  priorLabel: string | null;
  projected: boolean;
  row: PeriodRow;
  /** Share of the base row in the same column, when a base row is named. */
  share: number | null;
  unit: GridUnit;
  value: number | null;
}

/**
 * What each cell carries under its figure.
 *
 * Not a view switch. The figure is always the figure — a grid whose numbers
 * are replaced by percentages when a tab is pressed is a grid the reader has
 * to keep re-reading to work out what they are looking at, and the question
 * "what moved" is never asked *instead of* "how much is it". Both lines are
 * on screen together, or the change line is off.
 */
export interface GridAnnotations {
  /** Movement against the comparison column, under every figure. */
  change: boolean;
  /** Share of the named base row, in the same column. */
  share: boolean;
}

export interface PeriodGridProps {
  /** Index in `columns` of the last actual. Everything after it is projection. */
  actualThrough?: number;
  annotations?: Partial<GridAnnotations>;
  /** Names the table for a screen reader. Required — a grid of figures with no caption is a grid of figures. */
  caption: string;
  className?: string;
  columns: PeriodColumn[];
  currency: string;
  defaultAnnotations?: Partial<GridAnnotations>;
  density?: Density;
  /**
   * Which cells the reader may type into. Default: none — a grid over posted
   * figures is a report, and a report that accepts typing is a report someone
   * will believe they have changed.
   *
   * The obvious use is a driver model: the assumptions are editable, the
   * actuals behind them are not.
   */
  editable?: (row: PeriodRow, column: PeriodColumn) => boolean;
  /** Shown under the grid. The basis, the source, the rounding note. */
  footnote?: React.ReactNode;
  /** The frozen column's header. "Line", "Driver", "Category", "Vendor". */
  labelHeader?: string;
  labelWidth?: number;
  /**
   * Both thresholds must be exceeded before a change is flagged. A 200% swing
   * on $12 is not news and $40,000 on $8M is not either, and a grid that
   * flags both trains the reader to ignore the flag.
   */
  materiality?: { absolute: number; relative: number };
  /**
   * Caps the scroller so the header can freeze. Without a height the page
   * scrolls instead and there is nothing for `position: sticky` to stick to.
   */
  maxHeight?: number;
  onAnnotationsChange?: (annotations: GridAnnotations) => void;
  /**
   * A committed edit, in minor units. The grid does not hold the value — the
   * caller owns the model and re-renders with the new figure, because the
   * cells that *depend* on this one have to move at the same moment.
   */
  onCellEdit?: (edit: {
    rowId: string;
    columnId: string;
    rowIndex: number;
    columnIndex: number;
    value: number;
  }) => void;
  rows: PeriodRow[];
  /** The row every `share` percentage is taken against — usually revenue. */
  shareBase?: string;
  /** Offer the toggles that turn the two sub-lines on and off. */
  showAnnotationToggles?: boolean;
  showColumnManager?: boolean;
  /** Persists hidden columns and widths per user per grid. */
  storageKey?: string;
  /** Extra controls in the toolbar — a scenario picker, a period range. */
  toolbar?: React.ReactNode;
}

/* ── Density ─────────────────────────────────────────────────────────── */

const ROW_PADDING: Record<Density, string> = {
  comfortable: "px-4 py-3",
  compact: "px-2.5 py-1",
  default: "px-3 py-2",
};

const DEFAULT_ANNOTATIONS: GridAnnotations = { change: true, share: false };

/* ── Formatting ──────────────────────────────────────────────────────── */

/**
 * Non-money units are stored the way money is — as integers, scaled by 100 —
 * so that one grid can hold headcount, days sales outstanding and a gross
 * margin without three rounding conventions fighting inside one column.
 */
function formatUnit(value: number, unit: GridUnit, currency: string): string {
  switch (unit) {
    case "money":
      return formatMoney({ amount: value, currency }, { accounting: true });
    case "percent":
      /* `relative`, not `rate`: a rate is the three-decimal FX kind, and a
         growth rate printed to three decimals ("38.890%") reads as a
         precision the underlying figure does not have. */
      return formatPercent({ kind: "relative", value: value / 100 }, { signed: true });
    case "points":
      return formatPercent({ kind: "points", value: value / 100 }, { signed: true });
    case "ratio":
      return `${(value / 100).toFixed(2)}×`;
    case "days":
      return `${Math.round(value / 100)} d`;
    case "count":
      return (value / 100).toLocaleString(undefined, { maximumFractionDigits: 1 });
    default:
      return String(value);
  }
}

function rawOf(value: number, unit: GridUnit, currency: string): string {
  return unit === "money" ? rawMoney({ amount: value, currency }) : String(value / 100);
}

/* ── The grid ────────────────────────────────────────────────────────── */

/**
 * The spreadsheet an accountant already knows, with the discipline of a
 * statement underneath it.
 *
 * Everything that reads period-by-period — the forecast, budget versus
 * actual, the revenue tracker, the expense tracker, the driver model —
 * is a configuration of this one grid rather than another table written
 * from scratch. Six behaviours are the reason it exists at all, and every
 * one of them is something people already do in Excel within a minute of
 * opening a model:
 *
 * 1. **Freeze panes.** The top row and the left column. A wide grid whose
 *    account names scroll away is a grid of anonymous numbers, and the fix
 *    is the first thing anyone does to a workbook.
 * 2. **Ruled cells.** A period grid is read down a column as often as
 *    across a row; a column with no left edge is one the eye loses.
 * 3. **Hide columns.** Nobody reviews fourteen months at once. They hide
 *    the ones they are not asking about — and hiding is not deleting, so
 *    the data behind a hidden column keeps its index.
 * 4. **Drag the column edges.** Account names have no natural length.
 * 5. **The change under the figure.** The figure answers "how much"; the
 *    reader's next question is always "versus what", and asking it should
 *    not cost a hover, a click or a second table. It is printed under every
 *    figure, small and toned, so a row can be read across for its movement
 *    and down its column for its amount without either one moving.
 * 6. **Arrow keys and ⌘C.** They arrived from a spreadsheet and they are
 *    going back to one — the clipboard carries the value, not the
 *    presentation, so the paste sums.
 *
 * What it deliberately refuses to do:
 *
 * - **It does not animate a figure.** A value that moves under the eye is a
 *   value that has to be read twice. The crosshair is the only thing that
 *   changes on hover, and it is not a number the reader might take as truth.
 * - **It does not hide a fact behind the pointer.** The movement is on
 *   screen for every cell at once, because the reason anyone opens this grid
 *   is to compare, and a comparison you can only make one cell at a time is
 *   not a comparison.
 * - **It does not invent a percentage.** No ratio against a zero base, none
 *   against a negative base. The sub-line says which, rather than printing a
 *   number that reads backwards.
 * - **It does not treat a missing figure as zero.** A month that has not
 *   posted renders as `n/a`, never as `0`, and its change stays absent too.
 */
export function PeriodGrid({
  caption,
  columns,
  rows,
  currency,
  labelHeader = "Line",
  storageKey,
  actualThrough,
  annotations: controlledAnnotations,
  defaultAnnotations,
  onAnnotationsChange,
  showAnnotationToggles = true,
  showColumnManager = true,
  shareBase,
  labelWidth = 220,
  density = "compact",
  maxHeight = 520,
  materiality,
  editable,
  onCellEdit,
  toolbar,
  footnote,
  className,
}: PeriodGridProps) {
  /* ── Column visibility ─────────────────────────────────────────────
     Held as the full ordered list rather than as a set of hidden ids, so
     that the column menu can reorder as well as hide and the two operations
     stay one piece of state. */
  const [managed, setManaged] = React.useState<ManagedColumn[]>(() =>
    columns.map((c) => ({
      id: c.id,
      label: c.label,
      locked: c.locked,
      visible: !c.hidden,
    })),
  );

  /* A grid whose columns change shape — a scenario swap, a period range —
     re-seeds rather than carrying a stale visibility map for columns that no
     longer exist. Preferences for columns that survived are kept. */
  React.useEffect(() => {
    setManaged((current) => {
      const seen = new Map(current.map((c) => [c.id, c]));
      if (columns.length === current.length && columns.every((c) => seen.has(c.id))) {
        return current;
      }
      return columns.map((c) => ({
        id: c.id,
        label: c.label,
        locked: c.locked,
        visible: seen.get(c.id)?.visible ?? !c.hidden,
      }));
    });
  }, [columns]);

  const byId = React.useMemo(() => new Map(columns.map((c, i) => [c.id, i])), [columns]);

  /** Visible columns, in the reader's order, each carrying its source index. */
  const visible = React.useMemo(
    () =>
      managed
        .filter((m) => m.visible)
        .map((m) => ({ column: columns[byId.get(m.id) ?? 0], index: byId.get(m.id) ?? 0 }))
        .filter((c) => c.column != null),
    [managed, columns, byId],
  );

  const hiddenCount = managed.length - visible.length;

  /* ── Widths ────────────────────────────────────────────────────────── */
  const widthSpecs = React.useMemo(
    () => [
      { id: "__label", max: 480, min: 120, width: labelWidth },
      ...columns.map((c) => ({ id: c.id, max: 280, min: 72, width: c.width ?? 116 })),
    ],
    [columns, labelWidth],
  );
  const { widths, resizing, startResize, onResizeKeyDown, reset } = useColumnWidths({
    columns: widthSpecs,
    storageKey,
  });

  /* ── Annotations ───────────────────────────────────────────────────── */
  const [uncontrolled, setUncontrolled] = React.useState<GridAnnotations>({
    ...DEFAULT_ANNOTATIONS,
    ...defaultAnnotations,
  });
  const merged: GridAnnotations = { ...uncontrolled, ...controlledAnnotations };
  const setAnnotation = (key: keyof GridAnnotations, on: boolean) => {
    const next = { ...merged, [key]: on };
    setUncontrolled(next);
    onAnnotationsChange?.(next);
  };

  const baseRow = React.useMemo(
    () => (shareBase ? rows.find((r) => r.id === shareBase) : undefined),
    [rows, shareBase],
  );
  /* "% of" is a question about a base that has to exist. Offering the toggle
     with nothing to divide by would print a column of n/a and look broken. */
  const annotations: GridAnnotations = {
    change: merged.change,
    share: merged.share && baseRow != null,
  };

  /* ── Hover ─────────────────────────────────────────────────────────── */
  const [hover, setHover] = React.useState<{ row: number; col: number } | null>(null);

  /* ── Editing ───────────────────────────────────────────────────────── */
  const [editing, setEditing] = React.useState<{ row: number; col: number } | null>(null);
  /* The figure is what gets typed into — never a sub-line. A change or a
     share is arithmetic over two cells, and there is no single figure an
     edit to one of them would mean. */
  const canEdit = React.useCallback(
    (rowIndex: number, visibleIndex: number) => {
      if (!editable || !onCellEdit) return false;
      const row = rows[rowIndex];
      const column = visible[visibleIndex]?.column;
      return row != null && column != null && editable(row, column);
    },
    [editable, onCellEdit, rows, visible],
  );

  const commitEdit = React.useCallback(
    (rowIndex: number, visibleIndex: number, text: string) => {
      setEditing(null);
      const row = rows[rowIndex];
      const source = visible[visibleIndex];
      if (!row || !source || !onCellEdit) return;
      /* The repo's own parser, so a cell accepts everything the amount input
         does — "1.2k", "(1,240)", and "=1200+340", which is the habit every
         reader of this grid brings from the spreadsheet it replaced. */
      const parsed = parseMoneyInput(text, currency);
      if (parsed == null) return;
      /* Non-money units are held scaled by 100 too, so a headcount typed as
         "12" is stored as 1200 — the same integer contract as money, and the
         same single rounding at the end. */
      onCellEdit({
        columnId: source.column.id,
        columnIndex: source.index,
        rowId: row.id,
        rowIndex,
        value: parsed,
      });
    },
    [rows, visible, onCellEdit, currency],
  );

  /* ── Scroll shadows ────────────────────────────────────────────────── */
  const scrollerRef = useScrollEdges<HTMLDivElement>();

  /* ── Keyboard ──────────────────────────────────────────────────────── */
  const cellText = React.useCallback(
    (rowIndex: number, colIndex: number) => {
      const row = rows[rowIndex];
      if (!row) return { text: "" };
      /* Column 0 is the frozen label column; the figures start at 1. */
      if (colIndex === 0) return { text: row.label };
      const source = visible[colIndex - 1];
      if (!source) return { text: "" };
      const value = row.values[source.index] ?? null;
      if (value == null) return { text: "n/a" };
      const unit = row.unit ?? "money";
      return { raw: rawOf(value, unit, currency), text: formatUnit(value, unit, currency) };
    },
    [rows, visible, currency],
  );

  /* The keyboard cursor, readable from a callback that was created before
     the hook returned. `useGridNav` hands `onActivate` the row only, and
     opening a cell for editing needs the column too. */
  const cursorRef = React.useRef({ col: 0, row: 0 });

  const nav = useGridNav({
    colCount: visible.length + 1,
    getCell: cellText,
    /* Enter opens the focused cell, the way it does in every spreadsheet —
       and only where the model says typing is allowed. */
    onActivate: () => {
      const { row, col } = cursorRef.current;
      if (col > 0 && canEdit(row, col - 1)) setEditing({ col: col - 1, row });
    },
    onEscape: () => setHover(null),
    rowCount: rows.length,
  });
  cursorRef.current = nav.cell;

  /* ── Reading a cell ────────────────────────────────────────────────── */

  /**
   * Everything the chip, the formula bar and the `aria-label` say about one
   * cell, computed once. Three call sites reading the same figure three ways
   * is how a tooltip ends up disagreeing with the row it is sitting on.
   */
  const readCell = React.useCallback(
    (rowIndex: number, visibleIndex: number): PeriodCellRead | null => {
      const row = rows[rowIndex];
      const here = visible[visibleIndex];
      if (!row || !here) return null;

      const unit = row.unit ?? "money";
      const value = row.values[here.index] ?? null;

      /* A declared comparison wins over position, and it is resolved against
         the full column list rather than the visible one — hiding the budget
         column must not silently re-point the variance at last month. */
      const declared = here.column.compare != null ? byId.get(here.column.compare) : undefined;
      const previous =
        declared != null
          ? { column: columns[declared], index: declared }
          : visibleIndex > 0
            ? visible[visibleIndex - 1]
            : null;
      const priorValue = previous ? (row.values[previous.index] ?? null) : null;
      const change = periodChange(value, priorValue, row.polarity ?? "standard");
      /* A share is a mix question, and it is only a mix question between two
         figures of the same unit: "51.8% of operating expense" is a sentence
         about money, and asking it of a margin row would divide a percentage
         by a currency. */
      const share =
        baseRow && unit === "money" ? shareOfBase(value, baseRow.values[here.index] ?? null) : null;

      const material =
        materiality != null &&
        change.absolute != null &&
        Math.abs(change.absolute) >= materiality.absolute &&
        change.relative != null &&
        Math.abs(change.relative) >= materiality.relative;

      return {
        change,
        column: here.column,
        material,
        /* With thresholds declared, the caller has already said what counts.
           Without them, a tenth is the fallback: big enough that an FP&A
           reader would ask about it, small enough that a real move is never
           silently hidden. */
        notable: materiality != null ? material : change.relative != null && Math.abs(change.relative) >= 10,
        priorLabel: previous?.column.label ?? null,
        projected: actualThrough != null && here.index > actualThrough,
        row,
        share,
        unit,
        value,
      };
    },
    [rows, visible, columns, byId, baseRow, actualThrough, materiality],
  );

  const focusRead = nav.cell.col > 0 ? readCell(nav.cell.row, nav.cell.col - 1) : null;
  const hoverRead = hover ? readCell(hover.row, hover.col) : null;
  /* The formula bar prefers the pointer, because the pointer is where the
     reader is looking; it falls back to the keyboard cursor so the bar is not
     blank for someone who never touches the mouse. */
  const barRead = hoverRead ?? focusRead;

  const moneyFormat = React.useCallback(
    (minor: number) => formatMoney({ amount: minor, currency }),
    [currency],
  );

  const totalCols = visible.length + 1;

  return (
    <div className={cn("space-y-2", className)} data-density={density} onPointerLeave={() => setHover(null)}>
      {/* ── Toolbar ─────────────────────────────────────────────────── */}
      {toolbar || showAnnotationToggles || showColumnManager ? (
        <div className="flex flex-wrap items-center gap-3">
          {toolbar}

          {showAnnotationToggles ? (
            <div
              aria-label="What is printed under each figure"
              className="inline-flex overflow-hidden rounded-sm border border-border"
              role="group"
            >
              <AnnotationToggle
                hint="Movement against the comparison column, under every figure"
                onClick={() => setAnnotation("change", !annotations.change)}
                pressed={annotations.change}
              >
                % change
              </AnnotationToggle>
              {baseRow ? (
                <AnnotationToggle
                  hint={`Share of ${baseRow.label}, within the same column`}
                  onClick={() => setAnnotation("share", !annotations.share)}
                  pressed={annotations.share}
                >
                  % of {baseRow.label}
                </AnnotationToggle>
              ) : null}
            </div>
          ) : null}

          {showColumnManager ? (
            <span className="flex items-center gap-2">
              <ColumnManager columns={managed} onChange={setManaged} />
              {/* The count is the recovery path: a reader who hid six columns
                  last quarter needs to know this grid is not the whole story. */}
              {hiddenCount > 0 ? (
                <button
                  className="text-caption text-text-secondary underline decoration-dotted underline-offset-2 hover:text-text-primary"
                  onClick={() => setManaged((current) => current.map((c) => ({ ...c, visible: true })))}
                  type="button"
                >
                  {hiddenCount} column{hiddenCount === 1 ? "" : "s"} hidden — show all
                </button>
              ) : null}
            </span>
          ) : null}
        </div>
      ) : null}

      {/* ── The grid ────────────────────────────────────────────────── */}
      <div className="overflow-hidden rounded-xl border border-border bg-surface">
        <div
          aria-label={caption}
          className="ledger-grid scrollbar-thin"
          onKeyDown={nav.onKeyDown}
          ref={scrollerRef}
          role="group"
          style={{ maxHeight }}
          /* One tab stop, then arrow keys — the ARIA grid pattern, and the
             only shape in which a 400-row grid is not 400 tab stops. */
          tabIndex={0}
        >
          <table className="w-full" style={{ minWidth: "100%", tableLayout: "fixed", width: "max-content" }}>
            <caption className="sr-only">{caption}</caption>

            <colgroup>
              <col style={{ width: widths.__label }} />
              {visible.map((c) => (
                <col key={c.column.id} style={{ width: widths[c.column.id] }} />
              ))}
            </colgroup>

            <thead>
              <tr>
                <th
                  className={cn("text-left text-label font-medium text-text-secondary", ROW_PADDING[density])}
                  data-frozen="left top"
                  scope="col"
                >
                  {labelHeader}
                  <ResizeHandle
                    column="__label"
                    label={labelHeader}
                    onResizeKeyDown={onResizeKeyDown}
                    reset={reset}
                    resizing={resizing}
                    startResize={startResize}
                    width={widths.__label}
                  />
                </th>

                {visible.map((c, i) => {
                  const projected = actualThrough != null && c.index > actualThrough;
                  const first =
                    projected &&
                    (i === 0 || (actualThrough != null && visible[i - 1].index <= actualThrough));
                  return (
                    <th
                      className={cn(
                        "text-right text-label font-medium text-text-secondary transition-colors duration-1 ease-standard",
                        ROW_PADDING[density],
                      )}
                      data-cross={hover?.col === i ? "head" : undefined}
                      data-frozen="top"
                      data-projected={projected ? (first ? "start" : "yes") : undefined}
                      key={c.column.id}
                      scope="col"
                    >
                      <span className="block truncate">
                        <span className="block truncate">
                          {c.column.label}
                          {c.column.kind && c.column.kind !== "actual" ? (
                            <span className="block text-caption font-normal text-text-tertiary">
                              {c.column.kind}
                            </span>
                          ) : null}
                          {c.column.hint ? (
                            <span className="block text-caption font-normal text-text-tertiary">
                              {c.column.hint}
                            </span>
                          ) : null}
                        </span>
                      </span>
                      <ResizeHandle
                        column={c.column.id}
                        label={c.column.label}
                        onResizeKeyDown={onResizeKeyDown}
                        reset={reset}
                        resizing={resizing}
                        startResize={startResize}
                        width={widths[c.column.id]}
                      />
                    </th>
                  );
                })}
              </tr>
            </thead>

            <tbody>
              {rows.map((row, rowIndex) => {
                const variant = row.variant ?? "line";
                const emphasised = variant === "subtotal" || variant === "total";
                const section = variant === "section";

                return (
                  <tr
                    className={cn("group", emphasised && "bg-surface-sunken", section && "bg-surface-sunken")}
                    data-cross={hover?.row === rowIndex ? "" : undefined}
                    data-row-variant={variant}
                    key={row.id}
                    style={
                      emphasised ? { ["--ledger-row-tint" as string]: "var(--surface-sunken)" } : undefined
                    }
                  >
                    <th
                      className={cn(
                        "text-left text-body font-normal transition-colors duration-1 ease-standard",
                        ROW_PADDING[density],
                        emphasised && "font-medium",
                        section && "text-label font-medium uppercase tracking-wide text-text-secondary",
                      )}
                      data-cross={hover?.row === rowIndex ? "head" : undefined}
                      data-frozen="left"
                      data-level={row.level}
                      scope="row"
                      style={{ paddingLeft: 10 + ((row.level ?? 1) - 1) * 14 }}
                    >
                      <span className="block truncate" title={row.label}>
                        {row.label}
                        {row.derived ? (
                          <span
                            className="ml-1.5 text-caption text-text-tertiary"
                            title="Computed here, not read from the ledger"
                          >
                            ƒ
                          </span>
                        ) : null}
                      </span>
                      {row.note ? (
                        <span className="block truncate text-caption text-text-tertiary">{row.note}</span>
                      ) : null}
                    </th>

                    {visible.map((c, colIndex) => {
                      if (section) {
                        return (
                          <td
                            data-projected={
                              actualThrough != null && c.index > actualThrough ? "yes" : undefined
                            }
                            key={c.column.id}
                          />
                        );
                      }

                      const read = readCell(rowIndex, colIndex);
                      const hovered = hover?.row === rowIndex && hover.col === colIndex;
                      const writable = canEdit(rowIndex, colIndex);
                      const isEditing = editing?.row === rowIndex && editing.col === colIndex && writable;
                      const focused = nav.cell.row === rowIndex && nav.cell.col === colIndex + 1;
                      const projected = read?.projected ?? false;
                      const first =
                        projected &&
                        (colIndex === 0 ||
                          (actualThrough != null && visible[colIndex - 1].index <= actualThrough));

                      return (
                        <td
                          className={cn(
                            "text-right align-middle transition-colors duration-1 ease-standard",
                            ROW_PADDING[density],
                            focused && "outline outline-2 -outline-offset-2 outline-[var(--accent)]",
                            writable && !isEditing && "cursor-cell",
                          )}
                          data-cell-hover={hovered ? "" : undefined}
                          data-cross={hover?.col === colIndex ? "" : undefined}
                          data-projected={projected ? (first ? "start" : "yes") : undefined}
                          key={c.column.id}
                          onDoubleClick={
                            writable ? () => setEditing({ col: colIndex, row: rowIndex }) : undefined
                          }
                          onFocus={() => nav.setCell({ col: colIndex + 1, row: rowIndex })}
                          onPointerEnter={() => setHover({ col: colIndex, row: rowIndex })}
                        >
                          {isEditing ? (
                            <CellEditor
                              initial={
                                read?.value == null
                                  ? ""
                                  : (read.unit ?? "money") === "money"
                                    ? formatMoneyInput(read.value, currency)
                                    : String(read.value / 100)
                              }
                              onCancel={() => setEditing(null)}
                              onCommit={(text) => commitEdit(rowIndex, colIndex, text)}
                            />
                          ) : (
                            <span
                              className={cn(
                                writable &&
                                  "underline decoration-dotted decoration-[var(--accent-border)] underline-offset-4",
                              )}
                            >
                              <CellFigure
                                annotations={annotations}
                                currency={currency}
                                emphasised={emphasised}
                                read={read}
                              />
                            </span>
                          )}
                        </td>
                      );
                    })}
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>

        {/* ── The formula bar ─────────────────────────────────────────
            Reserved height, always present. A strip that appears on hover
            would shift the page every time the pointer crossed the grid. */}
        <div
          aria-live="polite"
          className="flex min-h-[34px] flex-wrap items-center gap-x-3 gap-y-1 border-t border-border bg-surface-sunken px-3 py-1.5 text-caption text-text-secondary"
        >
          {barRead ? (
            <>
              <span className="font-medium text-text-primary">
                {barRead.row.label} · {barRead.column.label}
              </span>
              <span className="numeric">
                {barRead.value == null ? "n/a" : formatUnit(barRead.value, barRead.unit, currency)}
              </span>
              <span>
                {describeChange(barRead.change, moneyFormat, barRead.priorLabel ?? "the previous column")}
              </span>
              {barRead.share != null ? (
                <span className="numeric">
                  {barRead.share.toFixed(1)}% of {baseRow?.label}
                </span>
              ) : null}
              {barRead.projected ? (
                <span className="rounded-xs bg-surface px-1.5 py-0.5 text-text-tertiary">
                  projection, not an actual
                </span>
              ) : null}
            </>
          ) : (
            <span className="text-text-tertiary">
              Point at a row for its movement. Arrow keys navigate, ⌘C copies values.
            </span>
          )}
        </div>
      </div>

      {footnote ? <div className="text-caption text-text-tertiary">{footnote}</div> : null}
      <span className="sr-only">
        Grid of {rows.length} rows and {totalCols - 1} periods.
      </span>
    </div>
  );
}

/* ── The toolbar toggles ─────────────────────────────────────────────── */

/**
 * On or off, not one-of-four. Each toggle adds a line under the figures; it
 * never replaces them, so the reader can never end up looking at a grid whose
 * numbers are not the numbers.
 */
function AnnotationToggle({
  pressed,
  onClick,
  hint,
  children,
}: {
  pressed: boolean;
  onClick: () => void;
  hint: string;
  children: React.ReactNode;
}) {
  return (
    <button
      aria-pressed={pressed}
      className={cn(
        "h-8 border-r border-border px-2.5 text-label transition-colors duration-1 ease-standard last:border-r-0",
        pressed ? "bg-accent text-accent-fg" : "bg-surface text-text-secondary hover:bg-surface-hover",
      )}
      onClick={onClick}
      title={hint}
      type="button"
    >
      {children}
    </button>
  );
}

/* ── Cell contents ───────────────────────────────────────────────────── */

/**
 * One cell: the figure, and under it what the figure is being compared to.
 *
 * The figure never moves and never changes shape — it is the same money, in
 * the same place, whatever the toggles say. The sub-line is smaller, toned
 * down and reserved: turning it on must not make the reader hunt for the
 * amount they already had their eye on.
 */
function CellFigure({
  read,
  annotations,
  currency,
  emphasised,
}: {
  read: PeriodCellRead | null;
  annotations: GridAnnotations;
  currency: string;
  emphasised: boolean;
}) {
  if (!read) return null;
  const { value, unit } = read;
  const size = emphasised ? "sm" : "xs";
  const tone = read.projected ? "secondary" : "primary";

  const figure =
    value == null ? (
      <Money size={size} value={null} />
    ) : unit === "money" ? (
      <Money accounting size={size} statement tone={tone} value={{ amount: value, currency } as MoneyValue} />
    ) : (
      <span
        className={cn(
          "numeric text-figure-xs",
          emphasised && "font-medium",
          read.projected && "text-text-secondary",
        )}
        data-raw={String(value / 100)}
      >
        {formatUnit(value, unit, currency)}
      </span>
    );

  /* A row that can never have a share does not get a column of placeholder
     dashes for one. */
  const withShare = annotations.share && unit === "money";
  if (!annotations.change && !withShare) return figure;

  return (
    <span className="block">
      <span className="block">{figure}</span>
      {/* One line, right-aligned under the figure it belongs to, with a
          reserved height so a row whose change is unavailable does not sit
          shorter than the row above it. */}
      <span className="flex min-h-[15px] items-baseline justify-end gap-1.5 leading-tight">
        {annotations.change ? <ChangeNote currency={currency} read={read} /> : null}
        {annotations.change && withShare ? (
          <span
            aria-hidden
            className={cn(
              "text-caption text-border",
              !read.notable && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
            )}
          >
            ·
          </span>
        ) : null}
        {withShare ? <ShareNote read={read} /> : null}
      </span>
    </span>
  );
}

/**
 * The movement.
 *
 * Printed outright where it is big enough to be the reason someone opened the
 * grid, and revealed on the row under the pointer everywhere else. Annotating
 * all ninety cells equally annotates none of them: the quiet months become
 * noise the eye has to filter before it can find the month that moved. The
 * space is reserved either way, so nothing reflows when a row lights up.
 *
 * Percentage rather than amount, because the amounts are already on screen
 * directly above and to the left — the percentage is the thing the eye
 * cannot do for itself while scanning seven columns. A percentage row is the
 * exception: the change in a margin is points, and calling a move from 51.8%
 * to 53.0% "+2.3%" is the most common way a deck lies by accident.
 */
function ChangeNote({ read, currency }: { read: PeriodCellRead; currency: string }) {
  const { change, unit, priorLabel } = read;

  /* Not `hidden`: the note keeps its box so the row height is the same
     whether or not the pointer is on it. */
  const reveal = read.notable
    ? ""
    : "opacity-0 transition-opacity duration-1 ease-standard group-hover:opacity-100 group-focus-within:opacity-100";

  const toneClass =
    change.direction === "favourable"
      ? "text-positive"
      : change.direction === "unfavourable"
        ? "text-negative"
        : "text-text-tertiary";

  /* No comparison column at all — the first period on screen. An en dash,
     not a zero: nothing moved because there is nothing to have moved from. */
  if (priorLabel == null) {
    return (
      <span
        className={cn("numeric text-caption text-text-tertiary", reveal)}
        title="no earlier column on screen"
      >
        –
      </span>
    );
  }

  if (unit === "percent" || unit === "points") {
    if (change.absolute == null) return <NoRatio change={change} className={reveal} />;
    return (
      <span className={cn("inline-flex items-baseline gap-0.5 text-caption", toneClass, reveal)}>
        {read.material ? <MaterialMark /> : null}
        <span className="numeric" title={`vs ${priorLabel}`}>
          {formatUnit(change.absolute, "points", currency)}
        </span>
      </span>
    );
  }

  if (change.relative == null) return <NoRatio change={change} className={reveal} />;

  return (
    <span className={cn("inline-flex items-baseline gap-0.5 text-caption", toneClass, reveal)}>
      {read.material ? <MaterialMark /> : null}
      <span
        className="numeric"
        title={`vs ${priorLabel}${
          change.absolute == null ? "" : ` · ${formatUnit(change.absolute, read.unit, currency)}`
        }`}
      >
        {change.relative > 0 ? "+" : change.relative < 0 ? "−" : ""}
        {Math.abs(change.relative).toFixed(1)}%
      </span>
      {change.signFlip ? (
        <span aria-hidden className="text-warning" title="crossed zero">
          ⌁
        </span>
      ) : null}
    </span>
  );
}

/** The share of the base row. Never toned — a share is a fact, not news. */
function ShareNote({ read }: { read: PeriodCellRead }) {
  if (read.share == null) {
    return (
      <span
        className="numeric text-caption text-text-tertiary"
        title="no share — the base is zero or missing"
      >
        —
      </span>
    );
  }
  return <span className="numeric text-caption text-text-tertiary">{read.share.toFixed(1)}%</span>;
}
/**
 * One cell, open for typing.
 *
 * It commits on Enter and on blur, and abandons on Escape — the three
 * gestures a spreadsheet has trained into everyone who will use this. It
 * selects its contents on mount, because the reader who opened a cell holding
 * 412,000 almost always means to replace it rather than to append to it.
 *
 * The parsing is `parseMoneyInput`, so the cell accepts "1.2k", "(1,240)" and
 * "=1200+340" — and a value it cannot parse commits nothing rather than
 * committing zero. A mistyped cell that silently becomes 0 is the worst
 * outcome available here.
 */

function CellEditor({
  initial,
  onCommit,
  onCancel,
}: {
  initial: string;
  onCommit: (text: string) => void;
  onCancel: () => void;
}) {
  const ref = React.useRef<HTMLInputElement>(null);
  React.useEffect(() => {
    ref.current?.focus();
    ref.current?.select();
  }, []);

  return (
    <input
      aria-label="Edit value"
      className="numeric w-full rounded-xs border border-accent bg-surface px-1 py-0.5 text-right text-figure-xs outline-none"
      defaultValue={initial}
      inputMode="decimal"
      onBlur={(e) => onCommit(e.currentTarget.value)}
      onKeyDown={(e) => {
        e.stopPropagation();
        if (e.key === "Enter") {
          e.preventDefault();
          onCommit(e.currentTarget.value);
        } else if (e.key === "Escape") {
          e.preventDefault();
          onCancel();
        }
      }}
      ref={ref}
    />
  );
}

/**
 * The materiality flag. The same ▸ `VarianceCell` uses, because a reader who
 * has learned it in one table should not have to learn it again in another.
 */
function MaterialMark() {
  return (
    <span
      aria-label="material variance"
      className="text-[0.85em] leading-none"
      role="img"
      title="Exceeds both the absolute and the relative materiality threshold"
    >
      ▸
    </span>
  );
}

/**
 * The honest render of a percentage that does not exist. An em dash with the
 * reason on hover, never 0%, never ∞, never the absolute value dressed up as
 * a rate — each of which someone downstream would paste into a board deck.
 */
function NoRatio({ change, className }: { change: PeriodChange; className?: string }) {
  const why =
    change.ratioUnavailable === "zero-base"
      ? "no percentage — the base was zero"
      : change.ratioUnavailable === "sign-flip"
        ? "no percentage — the figure crossed zero"
        : "nothing to compare against";
  return (
    <span className={cn("numeric relative text-figure-xs text-text-tertiary", className)} title={why}>
      <span aria-hidden="true">—</span>
      <span className="sr-only select-none">{why}</span>
    </span>
  );
}

/* ── The drag handle ─────────────────────────────────────────────────── */

function ResizeHandle({
  column,
  label,
  width,
  resizing,
  startResize,
  onResizeKeyDown,
  reset,
}: {
  column: string;
  label: string;
  /** The column's current width: a focusable separator is a range, and has to say where it is. */
  width: number | undefined;
  resizing: string | null;
  startResize: (column: string, event: React.PointerEvent) => void;
  onResizeKeyDown: (column: string, event: React.KeyboardEvent) => void;
  reset: (column: string) => void;
}) {
  return (
    <span
      aria-label={`Resize ${label}`}
      aria-orientation="vertical"
      aria-valuenow={width === undefined ? undefined : Math.round(width)}
      aria-valuetext={width === undefined ? undefined : `${Math.round(width)} pixels`}
      className="ledger-col-resizer"
      data-resizing={resizing === column ? "" : undefined}
      onDoubleClick={() => reset(column)}
      onKeyDown={(e) => onResizeKeyDown(column, e)}
      onPointerDown={(e) => startResize(column, e)}
      role="separator"
      tabIndex={0}
    />
  );
}

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in Ledger & tables

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where PeriodGrid sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when Ledger & tables changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.