Search docs

Jump to any table example

Discord—

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
A1
Category
ABCDEF
1CategoryJanFebMarTotalAvg
2Rent18001800180054001800
3Groceries420465.53981283.5427.8333
4Transport858512029096.6667
5Utilities140155132427142.3333
6Subscriptions48486315953
7Total24932553.525137559.5503.9667
8Income32003200335097503250
9Saved707646.58372190.50.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.

src/components/spreadsheet/data-table.tsx
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.

src/components/spreadsheet/data-table.tsx
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.

src/components/spreadsheet/formula.ts
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.

src/components/spreadsheet/data-table.tsx
function commit(move?: Pos, refocus = true) {
const draft = editingRef.current
if (draft === null) return
writeCells([[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.

src/components/spreadsheet/data-table.tsx
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.