Shadcn Excel-like Table
A spreadsheet grid built on TanStack Table: A1 column and row headers, an active cell you move with the keyboard, range selection, type-to-edit, copy and paste, and formulas like =SUM(B2:D2) that recalculate as you edit.
$ npx shadcn add https://www.shad-table.dev/r/spreadsheet-table.json| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| 1 | Category | Jan | Feb | Mar | Total | Avg |
| 2 | Rent | 1800 | 1800 | 1800 | 5400 | 1800 |
| 3 | Groceries | 420 | 465.5 | 398 | 1283.5 | 427.8333 |
| 4 | Transport | 85 | 85 | 120 | 290 | 96.6667 |
| 5 | Utilities | 140 | 155 | 132 | 427 | 142.3333 |
| 6 | Subscriptions | 48 | 48 | 63 | 159 | 53 |
| 7 | Total | 2493 | 2553.5 | 2513 | 7559.5 | 503.9667 |
| 8 | Income | 3200 | 3200 | 3350 | 9750 | 3250 |
| 9 | Saved | 707 | 646.5 | 837 | 2190.5 | 0.2247 |
| 10 | ||||||
| 11 | ||||||
| 12 | ||||||
| 13 | ||||||
| 14 | ||||||
| 15 | ||||||
| 16 | ||||||
| 17 | ||||||
| 18 | ||||||
| 19 | ||||||
| 20 |
Type to edit · Enter or F2 edits · Shift + arrows select · Copy and paste work
How it works
1.Raw strings are the data; the table renders a computed copy
Every cell is stored exactly as typed, including formulas. The sheet is re-evaluated from those strings whenever one changes, and the table is handed the evaluated text. Editing, copying and clearing all work on raw input, while TanStack Table only ever sees display values.
const computed = useMemo(() => evaluateSheet(raw), [raw])const rows = useMemo<SheetRow[]>(() => computed.map((cells, r) => ({ n: r + 1, cells })),[computed],)
2.The active cell is state, not DOM focus
Cells are never focused individually. One keydown handler on the grid moves an { r, c } position, and the cell at that position gets the outline. That keeps arrow keys, Tab and typing-to-edit in one place and avoids hundreds of tab stops. A second position, the anchor, turns Shift + arrows and mouse drags into a rectangular range.
function select(pos: Pos, extend = false) {setActive(pos)if (!extend) setAnchor(pos)}
3.Formulas are parsed, not eval-ed
A small recursive-descent parser handles + - * /, parentheses, A1 references, ranges, and SUM, AVERAGE, MIN, MAX and COUNT. Cells are evaluated on demand and cached, so a formula can depend on another formula. A cell reached again while it is still being evaluated is a cycle and shows #CIRC! instead of overflowing the stack.
if (visiting.has(key)) throw err('#CIRC!')visiting.add(key)try {const value = compute(raw[r][c])cache.set(key, value)return value} finally {visiting.delete(key)}
4.The editor owns its keys and commits exactly once
While editing, the input stops key events from reaching the grid, so arrow keys move the caret instead of the selection. Enter and Tab commit and move on; Escape discards. Clicking away also commits through blur, which can fire again as the input unmounts, so the draft lives in a ref that is cleared on the first commit.
function commit(move?: Pos, refocus = true) {const draft = editingRef.currentif (draft === null) returnwriteCells([[active, draft]])setEditing(null)if (move) moveBy(move.r, move.c)if (refocus) gridRef.current?.focus()}
5.Copy and paste use plain tab-separated text
Copy writes the selected cells as rows separated by newlines and columns by tabs, the format Excel and Google Sheets use, so ranges move between this table and a real spreadsheet. Paste splits the same format and writes it from the top-left of the selection, clipping anything beyond the grid.
e.clipboardData.setData('text/plain', lines.join(''))// ...const pasted = text.split('').map((line) => line.split(' '))
Using TanStack Table v9? See the v9 version of Excel-like Table.