Mini Spreadsheet Grid

A small bounded rows × cols spreadsheet grid composed of <SpreadsheetCell> children. The grid is pure render — cells live in a sparse cells map, formula results flow in through an injectable evaluate(cellId, cells) callback, and selection / dependency highlighting are controlled via highlightedCell and affectedCells. Arrow keys, Home, End, and the Ctrl + Home / End combos move focus across the cells; each cell ships its own Excel-style edit model (double-click / Enter / F2 to edit, type-to-overwrite, Escape to cancel).

A
B
1
10
5
2
20
15
3
30
Customize
Shape
3
2
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/mini-spreadsheet-grid.json

Usage

import { MiniSpreadsheetGrid } from "@craft-bits/core";
 
<MiniSpreadsheetGrid
  rows={6}
  cols={4}
  cells={{
    A1: { raw: "10" },
    A2: { raw: "20" },
    A3: { raw: "=A1+A2" },
  }}
  evaluate={(id, cells) => myEvaluator(id, cells)}
  onCellChange={(id, next, meta) => dispatch({ type: "edit", id, next, meta })}
/>

Drive the cursor / selection from your own algorithm reducer — the grid is pure render:

<MiniSpreadsheetGrid
  rows={rows}
  cols={cols}
  cells={model}
  evaluate={(id) => evaluate(id, model)}
  highlightedCell={cursor}
  affectedCells={downstream}
  onCellSelect={(id) => setCursor(id)}
  onCellChange={(id, next, meta) => dispatch({ type: "edit", id, next, meta })}
/>

Named columns (a table-style grid):

<MiniSpreadsheetGrid
  rows={4}
  cols={["Name", "Qty", "Price"]}
  cellId={(_, _c, label) => label}
  cells={{ Name: { raw: "Tomato" }, Qty: { raw: "3" }, Price: { raw: "1.20" } }}
/>

Headerless minimal grid — drop the column letters and row numbers:

<MiniSpreadsheetGrid rows={3} cols={3} hideHeaders />

Understanding the component

  1. Pure-render shell over <SpreadsheetCell>. The grid renders a flat rows-by-cols matrix of <SpreadsheetCell> children. Each cell looks up its record by id in the cells map; missing keys render an empty cell. Selection, affected, error, and bold all stay on individual cell records so the parent never has to re-derive them.
  2. Coordinate strategy. The default cellId(row, col, columnLabel) builds the familiar A1 / B2 ids. Override cellId to namespace cells (e.g. Sheet1!A1) or to use the column header label directly for named-column tables. The grid passes the same id back to every callback, so the parent's cells map and the grid's DOM stay in lockstep.
  3. Injectable formula evaluator. Pass an evaluate(id, cells) callback to compute each cell's display. The grid does not parse =A1+A2 itself — the convention is to keep the parser in the consumer so each app can ship its own grammar (sums only, range refs, custom functions, …). Returning null or undefined falls back to the cell's own display or raw.
  4. Keyboard navigation. Arrow keys move focus by one cell; Home moves to the first column of the current row; Ctrl + Home jumps to A1; End and Ctrl + End mirror those at the other corner. The grid only intercepts keys when no cell is editing, so edit-mode typing inside a <SpreadsheetCell> is never swallowed.
  5. Controlled editing (optional). Omit editingCell and each <SpreadsheetCell> self-manages edit mode — double-click, Enter, F2, or type-to-overwrite all start editing in place. Pass editingCell plus onEditStart / onCellChange / onEditCancel to drive edit mode from outside (e.g. an "F2 to edit currently selected cell" command in a larger UI).
  6. Header & sizing tokens. Header cells use the same cb-border-muted / cb-bg-muted tokens as the rest of the library so the grid themes correctly in light and dark. headerSize, rowHeight, and minCellWidth are CSS lengths — override to pack more cells into a small canvas or to give the grid more breathing room.

Props

PropTypeDefaultDescription
rowsnumber6Number of body rows.
colsnumber | string[]4Either a column count (auto-labelled A, B, …) or an explicit array of labels.
cellsRecord<string, MiniSpreadsheetGridCell>{}Sparse map of cell id to record. Missing ids render empty.
evaluate(id, cells) => ReactNodeOptional evaluator. Result becomes the cell's display. Return null or undefined to skip.
cellId(row, col, columnLabel) => stringA1 / B2 / …Stable id builder per cell.
highlightedCellstring | nullnullCell id rendered with state="highlight".
affectedCellsSet<string>emptyCell ids rendered with state="affected".
editingCellstring | nullControlled edit-mode cell. Omit to let cells self-manage.
onCellChange(id, next, meta) => voidFired on commit. meta is a cellId / raw / formula record.
onEditStart(id) => voidFired when a cell asks to enter edit mode.
onEditCancel(id) => voidFired on Escape inside a cell.
onCellSelect(id) => voidFired on focus / arrow-key navigation.
captionReactNodeOptional caption rendered above the grid.
headerSizestring'28px'Header cell length.
rowHeightstring'28px'Body cell height.
minCellWidthstring'72px'Body cell minimum width.
hideHeadersbooleanfalseHide the column-letter / row-number headers.
aria-labelstring'Mini spreadsheet'Accessible name on the role="grid" root.
classNamestringMerged onto the root <div>.

MiniSpreadsheetGridCell

FieldTypeDescription
rawstringWhat the user authored. Strings starting with = (after trim) are formulas.
displayReactNodeOptional explicit display value. Overridden by evaluate when provided.
errorstring | nullEvaluation error. Replaces display on that cell.
stateSpreadsheetCellStatePer-cell state override. Wins over highlightedCell / affectedCells.
boldbooleanRender the cell text bold.

Accessibility

  • The root is role="grid" with an aria-label (defaults to "Mini spreadsheet"). Override via aria-label when the page contains multiple grids.
  • Header rows are role="row" with role="columnheader" / role="rowheader" children so screen readers announce both the column letter and the row number when focus moves into a cell.
  • Body cells inherit the <SpreadsheetCell> a11y model — focusable when idle, real <input> while editing, accessible names like A1: 10 or A1: empty.
  • Keyboard: Arrow keys, Home, End move focus by one cell. Ctrl + Home jumps to the top-left corner; Ctrl + End to the bottom-right. Double-click, Enter, F2, or any printable key on an idle cell starts editing; Enter or blur commits; Escape cancels.
  • Arrow-key handling calls event.preventDefault() so the surrounding page does not scroll, and editing cells switch their wrapper tabIndex to -1 so Tab does not hop back out mid-edit.
  • Color is never the only signal: state="highlight" adds an accent ring, state="affected" adds a tinted fill, and state="dimmed" lowers opacity — all on top of the cell's text content rather than replacing it.

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/sdp-spreadsheet/ui/MiniSpreadsheetGrid.tsx). The original was a fixed-size 6x4 grid wired to the project's useSpreadsheet() context and <CellComponent>. The library extract replaces the context coupling with a cells map prop, replaces fixed GRID_ROWS / GRID_COLS constants with configurable rows / cols (numeric or named labels), parameterizes coordinate generation via cellId, and lifts formula evaluation into an injectable evaluate(id, cells) callback so the same component can power any spreadsheet shape — Excel-like A1 / B2, named-column tables, or fully custom coordinate systems. The CSS-module chrome was replaced with Tailwind tokens (cb-border / cb-bg-muted) so the grid themes correctly under any palette. The arrow-key navigation handler was rewritten to use a precomputed id-to-coordinate map so multi-character column labels (AA, AB) work without the original cellId[0] parser break.