# Data table: Spreadsheet mode

> Edit the slotsmith data table like a spreadsheet: select cells, type over them, paste from Excel, drag the fill handle and undo.

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`.

```tsx
import { useState, type CSSProperties } from "react";
import { DataTable, type DataTableColumnDef, type DataTableValueChange } from "slotsmith/data-table";
import { DatePicker } from "slotsmith/date-picker";

type Product = {
  id: string;
  name: string;
  category: "hardware" | "software" | "service";
  price: number;
  stock: number;
  active: boolean;
  launch: string | null;
};

const products: Product[] = [
  { id: "p1", name: "Keyboard", category: "hardware", price: 49.0, stock: 120, active: true, launch: "2026-02-03" },
  { id: "p2", name: "Monitor", category: "hardware", price: 219.5, stock: 34, active: true, launch: "2026-03-17" },
  { id: "p3", name: "Licence", category: "software", price: 99.0, stock: 500, active: false, launch: null },
  { id: "p4", name: "Support plan", category: "service", price: 15.0, stock: 0, active: true, launch: "2026-05-01" },
  { id: "p5", name: "Dock", category: "hardware", price: 129.9, stock: 12, active: true, launch: "2026-06-22" },
  { id: "p6", name: "Install", category: "service", price: 60.0, stock: 8, active: false, launch: "2026-07-09" },
];

const categories = [
  { value: "hardware", label: "Hardware" },
  { value: "software", label: "Software" },
  { value: "service", label: "Service" },
] as const;

// `edit` is typed from each accessorKey: `price` gets a number, `category` the union.
const columns: DataTableColumnDef<Product>[] = [
  { accessorKey: "name", header: "Name", edit: { validate: (name) => (name.trim() ? null : "A name is required") } },
  {
    accessorKey: "category",
    header: "Category",
    cell: ({ getValue }) => categories.find((category) => category.value === getValue())?.label,
    edit: { options: categories },
  },
  {
    accessorKey: "price",
    header: "Price",
    meta: { align: "end" },
    cell: ({ getValue }) => (getValue() as number).toFixed(2),
    edit: {
      parse: (text) => Number(text.replace(/[^\d.-]/g, "")),
      format: (price) => price.toFixed(2),
      validate: (price) => (Number.isNaN(price) ? "Enter a price" : price < 0 ? "Must be positive" : null),
    },
  },
  { accessorKey: "stock", header: "Stock", meta: { align: "end" }, edit: true },
  { accessorKey: "active", header: "Active", edit: { editor: "checkbox" } },
  { accessorKey: "launch", header: "Launch", edit: { editor: "date", empty: null } },
];

// The page's own text colour, so the button reads in light and dark themes.
const resetStyle: CSSProperties = { font: "inherit", color: "inherit", background: "none", border: "1px solid", borderRadius: 4, padding: "0.125rem 0.625rem" };

export default function Spreadsheet() {
  // The table does not change its rows itself: the rows live here.
  const [rows, setRows] = useState(products);
  const [change, setChange] = useState<DataTableValueChange<Product> | null>(null);

  return (
    <>
      <DataTable
        value={rows}
        onValueChange={(next, details) => {
          setRows(next);
          setChange(details);
        }}
        columns={columns}
        getRowId={(product) => product.id}
        // The date column opens the slotsmith DatePicker; without it, the browser's date input.
        spreadsheet={{ datePicker: DatePicker }}
        enablePagination={false}
      />
      <p>
        <button
          type="button"
          style={resetStyle}
          onClick={() => {
            setRows(products);
            setChange(null);
          }}
        >
          Reset
        </button>{" "}
        <span dir="ltr">
          {change
            ? `Last change: ${change.source}, rows ${change.rows.map((row) => row.rowId).join(", ")}`
            : "Click a cell and type, paste from a spreadsheet, or drag the corner square."}
        </span>
      </p>
    </>
  );
}
```

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`](https://slotsmith.dev/components/data-table/api/#props) 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.

```tsx
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:

```tsx
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](https://slotsmith.dev/components/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](https://slotsmith.dev/components/date-picker/) 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`](https://slotsmith.dev/components/data-table/api/#slot-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`](https://slotsmith.dev/guides/#once-for-the-whole-app) 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:

| Keys | Action |
| --- | --- |
| ←, →, ↑, ↓ | Move to the next cell. With Shift, extend the selection; with Ctrl or ⌘, jump to the edge of the data. |
| Home, End | Move to the row's first or last cell; with Ctrl or ⌘, to the grid's first or last cell. |
| PageUp, PageDown | Move a screen of rows up or down. |
| Tab | Move to the next cell; at the row's end, leave the grid. |
| Enter, F2 | Open the cell's editor. |
| Delete | Clear the selected cells. |
| Ctrl+C, Ctrl+X, Ctrl+V | Copy, cut or paste the selection as tab-separated text. |
| Ctrl+Z, Ctrl+Y | Undo or redo the last change. |
| Ctrl+D, Ctrl+R | Fill down or right from the selection's first row or column. |
| Ctrl+A | Select every cell. |
| Shift+Space, Ctrl+Space | Select the row or the column. |
| Space | Flip a checkbox cell. |
| Esc | Collapse 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`](https://slotsmith.dev/components/data-table/api/#props) 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](https://slotsmith.dev/components/data-table/guides/tree-rows/) 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](https://slotsmith.dev/components/data-table/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:

```tsx
import { useState } from "react";
import { DataTable } from "slotsmith/data-table";
import { DatePicker } from "slotsmith/date-picker";
import { SlotsmithProvider } from "slotsmith/provider";
import { muiComponents as autocomplete } from "../adapters/autocomplete/mui";
import { muiComponents as dataTable } from "../adapters/data-table/mui";
import { muiComponents as datePicker } from "../adapters/date-picker/mui";
import { productColumns, products } from "../shared/products";

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

  // One provider skins the table, and the autocomplete and date picker its select and date cells open.
  return (
    <SlotsmithProvider components={{ dataTable, autocomplete, datePicker }}>
      <DataTable
        value={rows}
        onValueChange={(next) => setRows(next)}
        columns={productColumns}
        getRowId={(product) => product.id}
        spreadsheet={{ datePicker: DatePicker }}
        enablePagination={false}
      />
    </SlotsmithProvider>
  );
}
```

shadcn/ui:

```tsx
import { useState } from "react";
import { DataTable } from "slotsmith/data-table";
import { DatePicker } from "slotsmith/date-picker";
import { SlotsmithProvider } from "slotsmith/provider";
import { shadcnComponents as autocomplete } from "../adapters/autocomplete/shadcn";
import { shadcnComponents as dataTable } from "../adapters/data-table/shadcn";
import { shadcnComponents as datePicker } from "../adapters/date-picker/shadcn";
import { productColumns, products } from "../shared/products";

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

  // One provider skins the table, and the autocomplete and date picker its select and date cells open.
  return (
    <SlotsmithProvider components={{ dataTable, autocomplete, datePicker }}>
      <DataTable
        value={rows}
        onValueChange={(next) => setRows(next)}
        columns={productColumns}
        getRowId={(product) => product.id}
        spreadsheet={{ datePicker: DatePicker }}
        enablePagination={false}
      />
    </SlotsmithProvider>
  );
}
```

## 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`](https://slotsmith.dev/components/data-table/api/#slot-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](https://slotsmith.dev/components/data-table/api/#labels) translates them: [`editCell`](https://slotsmith.dev/components/data-table/api/#label-editCell) names the editor, [`cellsCopied`](https://slotsmith.dev/components/data-table/api/#label-cellsCopied), [`cellsCut`](https://slotsmith.dev/components/data-table/api/#label-cellsCut), [`cellsPasted`](https://slotsmith.dev/components/data-table/api/#label-cellsPasted), [`cellsCleared`](https://slotsmith.dev/components/data-table/api/#label-cellsCleared), [`cellsFilled`](https://slotsmith.dev/components/data-table/api/#label-cellsFilled), [`undone`](https://slotsmith.dev/components/data-table/api/#label-undone) and [`redone`](https://slotsmith.dev/components/data-table/api/#label-redone) are the announcements, and [`invalidNumber`](https://slotsmith.dev/components/data-table/api/#label-invalidNumber), [`invalidOption`](https://slotsmith.dev/components/data-table/api/#label-invalidOption), [`invalidDate`](https://slotsmith.dev/components/data-table/api/#label-invalidDate) and [`invalidBoolean`](https://slotsmith.dev/components/data-table/api/#label-invalidBoolean) are the built-in validation messages.

- One rectangle is selected at a time; Ctrl+click does not add another.
- A paste that runs past the last row is cut off; it does not add rows.
- With pagination, the keys move within the page.
