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.jsonUsage
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
- 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 thecellsmap; 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. - Coordinate strategy. The default
cellId(row, col, columnLabel)builds the familiar A1 / B2 ids. OverridecellIdto 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'scellsmap and the grid's DOM stay in lockstep. - Injectable formula evaluator. Pass an
evaluate(id, cells)callback to compute each cell's display. The grid does not parse=A1+A2itself — the convention is to keep the parser in the consumer so each app can ship its own grammar (sums only, range refs, custom functions, …). Returningnullorundefinedfalls back to the cell's owndisplayorraw. - 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. - Controlled editing (optional). Omit
editingCelland each<SpreadsheetCell>self-manages edit mode — double-click, Enter, F2, or type-to-overwrite all start editing in place. PasseditingCellplusonEditStart/onCellChange/onEditCancelto drive edit mode from outside (e.g. an "F2 to edit currently selected cell" command in a larger UI). - Header & sizing tokens. Header cells use the same
cb-border-muted/cb-bg-mutedtokens as the rest of the library so the grid themes correctly in light and dark.headerSize,rowHeight, andminCellWidthare CSS lengths — override to pack more cells into a small canvas or to give the grid more breathing room.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 6 | Number of body rows. |
cols | number | string[] | 4 | Either a column count (auto-labelled A, B, …) or an explicit array of labels. |
cells | Record<string, MiniSpreadsheetGridCell> | {} | Sparse map of cell id to record. Missing ids render empty. |
evaluate | (id, cells) => ReactNode | — | Optional evaluator. Result becomes the cell's display. Return null or undefined to skip. |
cellId | (row, col, columnLabel) => string | A1 / B2 / … | Stable id builder per cell. |
highlightedCell | string | null | null | Cell id rendered with state="highlight". |
affectedCells | Set<string> | empty | Cell ids rendered with state="affected". |
editingCell | string | null | — | Controlled edit-mode cell. Omit to let cells self-manage. |
onCellChange | (id, next, meta) => void | — | Fired on commit. meta is a cellId / raw / formula record. |
onEditStart | (id) => void | — | Fired when a cell asks to enter edit mode. |
onEditCancel | (id) => void | — | Fired on Escape inside a cell. |
onCellSelect | (id) => void | — | Fired on focus / arrow-key navigation. |
caption | ReactNode | — | Optional caption rendered above the grid. |
headerSize | string | '28px' | Header cell length. |
rowHeight | string | '28px' | Body cell height. |
minCellWidth | string | '72px' | Body cell minimum width. |
hideHeaders | boolean | false | Hide the column-letter / row-number headers. |
aria-label | string | 'Mini spreadsheet' | Accessible name on the role="grid" root. |
className | string | — | Merged onto the root <div>. |
MiniSpreadsheetGridCell
| Field | Type | Description |
|---|---|---|
raw | string | What the user authored. Strings starting with = (after trim) are formulas. |
display | ReactNode | Optional explicit display value. Overridden by evaluate when provided. |
error | string | null | Evaluation error. Replaces display on that cell. |
state | SpreadsheetCellState | Per-cell state override. Wins over highlightedCell / affectedCells. |
bold | boolean | Render the cell text bold. |
Accessibility
- The root is
role="grid"with anaria-label(defaults to "Mini spreadsheet"). Override viaaria-labelwhen the page contains multiple grids. - Header rows are
role="row"withrole="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 likeA1: 10orA1: 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 wrappertabIndexto-1so 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, andstate="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'suseSpreadsheet()context and<CellComponent>. The library extract replaces the context coupling with acellsmap prop, replaces fixedGRID_ROWS/GRID_COLSconstants with configurablerows/cols(numeric or named labels), parameterizes coordinate generation viacellId, and lifts formula evaluation into an injectableevaluate(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 originalcellId[0]parser break.