With spreadsheet the data table works like a spreadsheet: cells are selected by keyboard and pointer, a click opens a cell’s editor, ranges copy and paste to and from Excel and Google Sheets, the fill handle continues a series, and changes undo. Columns opt in with edit.

KeyboardHardware49.001202026-02-03
MonitorHardware219.50342026-03-17
LicenceSoftware99.00500
Support planService15.0002026-05-01
DockHardware129.90122026-06-22
InstallService60.0082026-07-09

Click a cell and type, paste from a spreadsheet, or drag the corner square.

Click a cell and type, press Enter to write and move down, or select a range with Shift and the arrow keys and press Delete. Copy a few cells from a spreadsheet and paste them here. The line under the table shows the last change; Reset puts the rows back.

Storing the rows

The rows are yours. Pass them as value, and store what onValueChange gives you: the table shows a change only once value holds it. value is the same as data, named like any other controlled value; pass one of them.

const [rows, setRows] = useState(products);

return <DataTable value={rows} onValueChange={(next) => setRows(next)} columns={columns} spreadsheet />;

onValueChange(value, change) receives the new rows and a DataTableValueChange:

  • source: what made the change: "edit", "paste", "cut", "clear", "fill", "undo" or "redo".
  • rows: every row that changed, each with its rowId, the row previous and next, and the columnIds whose cells changed. Send these to a server to save only what changed.

Only the rows that changed are new objects, so only they re-render. Moving the selection, and typing in an editor, re-render only the cells involved.

Editable columns

A column opts in with edit: true for the defaults, an object to configure them. A column without it is read-only, and its cells carry aria-readonly="true".

Every function in edit is typed by the column’s value. In a plain array of columns the value comes from the field the accessorKey names, so price in the sample below is a number. A column built from an accessor function, or whose key is only a string, takes its types from createDataTableColumnHelper, whose accessor also takes edit, as the value column below does:

import { useState } from "react";
import { createDataTableColumnHelper, DataTable, type DataTableColumnDef } from "slotsmith/data-table";

type Product = { id: string; sku: string; name: string; price: number; stock: number };

const products: Product[] = [
  { id: "p1", sku: "KB-01", name: "Keyboard", price: 49, stock: 120 },
  { id: "p2", sku: "MN-02", name: "Monitor", price: 219.5, stock: 34 },
  { id: "p3", sku: "DK-03", name: "Dock", price: 129.9, stock: 12 },
];

const columns: DataTableColumnDef<Product>[] = [
  { accessorKey: "sku", header: "SKU" },
  { accessorKey: "name", header: "Name", edit: true },
  {
    accessorKey: "price",
    header: "Price",
    edit: {
      parse: (text) => Number(text.replace(/[^\d.-]/g, "")),
      format: (price) => price.toFixed(2),
      validate: (price) => (price < 0 ? "Must be positive" : null),
    },
  },
];

// An accessor function's value is inferred by the column helper, which types `edit` too.
const column = createDataTableColumnHelper<Product>();
export const valueColumns = [
  column.accessor((row) => row.stock * row.price, {
    id: "value",
    header: "Stock value",
    edit: { setValue: (row, value) => ({ ...row, stock: Math.round(value / row.price) }) },
  }),
];

export default function SpreadsheetColumns() {
  const [rows, setRows] = useState(products);

  return <DataTable value={rows} onValueChange={(next) => setRows(next)} columns={columns} spreadsheet />;
}

The options of edit:

  • editable(row): whether a row’s cell can be edited. Defaults to every row.
  • editor: the built-in editor, "text", "number", "select", "date" or "checkbox". Defaults to "select" when options is set, otherwise to the kind that fits the cell’s value.
  • component: your own editor, a component that edits the column’s value directly. It receives the draft value, onValueChange, commit, cancel, the row, the columnId and the error.
  • options: the choices of a select, each a value and a label.
  • parse(text, row) and format(value, row): text to value, for typing and pasting, and value to text, for the editor and for copying. The defaults read numbers, option labels, ISO dates and TRUE/FALSE words.
  • validate(value, row): a message when the value is not allowed, null when it is.
  • setValue(row, value): writes a value into a copy of the row. An accessorKey column does this itself, also for a dotted key such as "address.city"; an accessor-function column needs it.
  • empty: the value Delete writes. Defaults to the parse of an empty string.

Editors

A click on an editable cell opens its editor over the cell, the same size, with its text selected: typing replaces it, and the arrow keys still move between cells. Typing on a selected cell opens the editor with what you typed. A click inside the editor’s text places the caret, and from then on the arrow keys move the caret; F2 switches between the two.

  • "text" and "number" edit the cell as text, in an input.
  • "select" opens the slotsmith Autocomplete on the column’s options and writes as soon as one is picked. Typing over the cell searches the options.
  • "date" writes an ISO date (YYYY-MM-DD), or null when cleared. Pass the slotsmith DatePicker as spreadsheet.datePicker and it opens that picker, writing as soon as a day is picked; without it the cell uses the browser’s <input type="date">, so a table that edits no dates does not ship the picker.
  • "checkbox" shows the table’s own Checkbox in the cell. A click or Space flips it, with no editor.

The editors are the CellEditor slot, so a design system’s adapter replaces them. Selects and dates use the slotsmith components, so they wear the adapters given to SlotsmithProvider like the rest of the app.

A value that fails parse or validate keeps its editor open with the message beside it, and the input carries aria-invalid. Moving on from a cell you have not changed never stops on its value, valid or not.

Keyboard

The table is an ARIA grid with one tab stop: the active cell. With focus on a cell:

KeysAction
←→↑↓Move to the next cell. With Shift, extend the selection; with Ctrl or ⌘, jump to the edge of the data.
HomeEndMove to the row's first or last cell; with Ctrl or ⌘, to the grid's first or last cell.
PageUpPageDownMove a screen of rows up or down.
TabMove to the next cell; at the row's end, leave the grid.
EnterF2Open the cell's editor.
DeleteClear the selected cells.
Ctrl+CCtrl+XCtrl+VCopy, cut or paste the selection as tab-separated text.
Ctrl+ZCtrl+YUndo or redo the last change.
Ctrl+DCtrl+RFill down or right from the selection's first row or column.
Ctrl+ASelect every cell.
Shift+SpaceCtrl+SpaceSelect the row or the column.
SpaceFlip a checkbox cell.
EscCollapse the selection to the active cell.

In an open editor, Enter writes the value and moves down (Shift+Enter up), Tab writes and moves to the next cell, and Esc closes the editor without writing. spreadsheet.enterMoves sets where Enter goes: "down", "right" or "none".

Copy, paste and fill

Copying puts the selection on the clipboard as tab-separated text and as an HTML table, so it pastes as a grid into spreadsheets and documents. Pasting reads tab-separated text from any spreadsheet. A block pastes from the selection’s top corner at its own size, or repeats to fill a selection that is a whole multiple of it, such as one value into a whole column. Each pasted value goes through its column’s parse and validate; cells that do not fit are skipped, and the count is announced.

Drag the small square on the selection’s corner, the fill handle, to fill the cells you drag over. Numbers with a constant step continue the series (1, 2 becomes 3, 4), text ending in a number counts on (Item 9 becomes Item 10), and anything else repeats. Ctrl+D and Ctrl+R copy the first row or column as it is. spreadsheet.fillHandle: false hides the handle.

Undo

Every change, whatever made it, is one step of the history: Ctrl+Z undoes it and Ctrl+Y or Ctrl+Shift+Z redoes it. Undo reports through onValueChange with the source "undo" or "redo", like any other change. spreadsheet.history sets how many steps are kept (100 by default; 0 turns undo off). From code, useDataTableContext().spreadsheet has undo() and redo(), for toolbar buttons.

Tree rows

Sub-rows of a tree are edited too when you pass spreadsheet.setSubRows(row, subRows), which writes new children into a copy of a row: the table needs it to write an edited sub-row back into its parent, up to the top level. Without it only top-level rows are editable.

With a design system

The ready-made adapters draw spreadsheet mode in each library’s look: the selection and the active cell in the library’s primary colour, a borderless input of the library’s own for text and numbers, its fill handle, and its checkbox in checkbox columns. Selects and dates open the slotsmith Autocomplete and DatePicker, so give their adapters to the same SlotsmithProvider and the whole sheet matches. Both tables below are the demo’s columns, with nothing changed but the provider.

MUI:

shadcn/ui:

Styling

Every grid cell carries data-column-id, and its part in the selection: data-active on the active cell, data-selected and data-range-edge (top, bottom, start, end) across a selected range, data-editing while it is edited, data-fill-preview under a fill drag, data-copied after a copy, and data-editable when it can be edited. The table carries data-spreadsheet. The built-in stylesheet draws them in the accent colour; a design system’s adapter styles them itself. The fill handle is the FillHandle slot.

Announcements and labels

A visually hidden live region reads each copy, cut, paste, clear, fill, undo and redo. The strings are labels, so a locale pack translates them: editCell names the editor, cellsCopied, cellsCut, cellsPasted, cellsCleared, cellsFilled, undone and redone are the announcements, and invalidNumber, invalidOption, invalidDate and invalidBoolean are the built-in validation messages.