# Editable Grid

> A React editable grid that works like a spreadsheet: typed cells edited in place, range selection, Excel copy and paste, undo, validation.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.1
- Install: `npx wingo-ui@latest add editable-grid`
- Docs: https://wingo-ui.com/components/editable-grid

## Interaction

Click a cell to select it and the outline glides there; Shift+click, Shift+arrows or a mouse drag select a range, a row number selects the row, a column header the column, and Ctrl/⌘+A everything. Type to replace a cell, or press Enter, F2 or double-click to edit it: Enter commits and goes down (Shift+Enter up), Tab commits and goes right, wrapping to the next row (Shift+Tab left), Escape cancels, clicking elsewhere commits. The grid is one Tab stop: outside an edit, Tab moves on to the next control. Selects and dates open their picker; Space toggles checkboxes. Arrows move, Ctrl/⌘+arrows jump to the edges, Home / End go to the row ends, Ctrl/⌘+Home / End to the grid corners, PageUp / PageDown by a page. Ctrl/⌘+C copies the range as tab separated values Excel and Sheets understand, Ctrl/⌘+X cuts, Ctrl/⌘+V pastes a block from the active cell (adding rows when needed), Delete or Backspace clears, Ctrl/⌘+Z undoes and Shift+Ctrl/⌘+Z or Ctrl+Y redoes. Bad values stay in the cell with a red corner and the message under the active cell; the toolbar's error count jumps to the next one. Drag a header edge to resize a column, double-click it to fit. Hover a row number (or right-click, Shift+F10) for insert, duplicate and delete. On a phone the grid scrolls both ways with the first column frozen: tap a cell to select it, tap it again (or the row number) to edit the whole row in a bottom sheet, long press a row number for its actions.

## Installation

```bash
npx wingo-ui@latest add editable-grid
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/editable-grid` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/editable-grid.tsx`

npm dependencies: `npm i @tanstack/react-virtual motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Date Picker](https://wingo-ui.com/components/date-picker) ([Markdown](https://wingo-ui.com/components/date-picker.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useLongPress](https://wingo-ui.com/components/use-long-press) ([Markdown](https://wingo-ui.com/components/use-long-press.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { EditableGrid, type GridColumn, type GridRow } from "@/components/ui/editable-grid"

type Product = GridRow & { name: string; category: string; price: number | null; stock: number | null; active: boolean }

const columns: GridColumn<Product>[] = [
  { id: "name", header: "Product", required: true, width: 240 },
  {
    id: "category",
    header: "Category",
    type: "select",
    options: [
      { value: "licenses", label: "Licenses" },
      { value: "services", label: "Services" },
    ],
  },
  { id: "price", header: "Price before tax", type: "currency", currency: "USD", min: 0 },
  { id: "stock", header: "Stock", type: "number", step: 1, min: 0 },
  { id: "active", header: "Active", type: "checkbox" },
]

export function PriceList() {
  const [rows, setRows] = useState<Product[]>([
    { id: "p-1", name: "Acme CRM Business license (month)", category: "licenses", price: 49, stock: 120, active: true },
    { id: "p-2", name: "Setup and training (hour)", category: "services", price: 65, stock: null, active: true },
  ])
  return (
    <EditableGrid
      title="Price list 2026"
      columns={columns}
      value={rows}
      onValueChange={setRows}
      locale="en-US"
      createRow={() => ({ id: crypto.randomUUID(), name: "", category: "licenses", price: null, stock: null, active: true })}
      labels={{ addRow: "New product" }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` | `GridColumn<R>[]` |  | The columns: { id (the row key), header, type (text, number, currency, select, date, checkbox), width, minWidth, options, currency, decimals, min, max, step, required, validate, readOnly (or a function of the row), align, format, placeholder, frozen }. Columns are not virtualized; a grid here has fewer than 30. |
| `value?` | `R[]` |  | The rows, controlled. Every row needs a unique string id. Pair with onValueChange. |
| `defaultValue?` | `R[]` | `"[]"` | The starting rows while uncontrolled. |
| `onValueChange?` | `(rows: R[]) => void` |  | Every change to the rows: edits, pastes, cleared ranges, added, deleted and restored rows. |
| `onCellChange?` | `(change: GridCellChange) => (void \| false \| Promise<unknown>)` |  | One committed cell ({ rowId, columnId, value, previous }). Return false or a rejected promise to put the old value back (save to a server and roll back on failure). |
| `onCellsChange?` | `(changes: GridCellChange[]) => (void \| false \| Promise<unknown>)` |  | Batches: a paste, a cleared range, a row saved from the sheet, undo and redo. Same revert contract as onCellChange. |
| `createRow?` | `(index: number) => R` | `"() => ({ id: crypto.randomUUID() })"` | Defaults for new rows (the add row button, inserts, rows a paste appends). An id is generated when it returns none. |
| `title?` | `ReactNode` |  | The toolbar's title, e.g. the sheet name. |
| `toolbarActions?` | `ReactNode` |  | Your own buttons at the end of the toolbar (export, import, filters). |
| `emptyState?` | `ReactNode` | `"an EmptyState with Add the first row"` | Shown under the headers when there are no rows. |
| `labels?` | `Partial<EditableGridLabels>` | `"DEFAULT_EDITABLE_GRID_LABELS"` | Every string in English: addRow, deleteRows(count, n), insertAbove, insertBelow, duplicate, edit, delete, undo, redo, errors(count, n), required, invalidNumber, invalidDate, notAnOption, tooLow, tooHigh, invalidStep, editRow(index), save, cancel, close, pasted(rows, cols), copied, cleared, rowsDeleted and the rest. Pass only the keys you translate. |
| `density?` | `"compact" \| "default"` | `"default"` | Row height 32 / 40px (40 / 44px on touch screens) and cell text 13 / 14px. |
| `rowHeader?` | `EditableGridRowHeader` | `"number"` | number, checkbox or none. The leading column: row numbers (click selects the row, hover shows the row menu) or checkboxes for bulk actions (the toolbar offers Delete N rows). |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The active cell outline, the range and the checkboxes. |
| `color?` | `string` | `""` | Any CSS color for the outline, the range and the checkboxes. Replaces the tone. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg"` | `"md"` | Corner radius of the panel: 0 / 12 / 16 / 24px. |
| `showToolbar?` | `boolean` | `true` | The toolbar row: title, error count, bulk delete (rows picked by their header or checkbox), undo / redo, and Add row once the rows scroll. A read-only grid with nothing to show there hides it. |
| `showErrors?` | `"cell" \| "summary" \| "both"` | `"both"` | cell: invalid cells get a tint, a red corner with the message and the message under the active cell. summary: an error count in the toolbar that jumps to the next one. both: the two. |
| `height?` | `number` | `480` | Max height of the scroller in px; the header sticks inside it. 0 grows with the rows and never virtualizes. |
| `frozenColumns?` | `number` | `1` | Columns that stay at the start edge while you scroll sideways (next to the row header), with a shadow once scrolled. |
| `resizableColumns?` | `boolean` | `true` | Drag a header's end edge to resize a column; double-click it to fit the content. |
| `allowAddRows?` | `boolean` | `true` | The Add row button and row, inserts from the row menu, and Enter on the last cell adding a row. |
| `allowDeleteRows?` | `boolean` | `true` | Delete in the row menu and the row sheet, and bulk delete in the toolbar. |
| `allowPasteNewRows?` | `boolean` | `true` | A paste taller than the rows left appends rows; off, the extra lines are dropped. |
| `validateOn?` | `"change" \| "blur"` | `"change"` | change: the cell editor flags a bad value while you type. blur: only once it is committed. Committed cells are always checked (required, type, min / max / step, options, validate) and bad values are kept so they can be fixed. |
| `readOnly?` | `boolean` | `false` | Selection and copy only: no editing, paste, add or delete. On phones the row sheet shows the row read-only with a Close button. |
| `responsive?` | `boolean` | `true` | Below 768px a second tap edits the whole row in a bottom sheet and the row menu is an action sheet. |
| `locale?` | `string` | `""` | Numbers, money, dates and paste parsing ("1,234.50" is 1234.5 in en-US, "1.234,50" in ro-RO). Empty uses the UIProvider or LocaleProvider locale, then the browser's. The paste parsers are exported too (see Parts). |
| `virtualThreshold?` | `number` | `100` | Above this many rows only the visible ones render (fixed row height). Needs a height; 0 always virtualizes. |
| `rowReadOnly?` | `(row: R) => boolean` |  | Rows that can be selected and copied but not edited (a locked invoice line, an approved timesheet). |
| `defaultSelection?` | `GridSelection` |  | The selection on mount: { anchor: { rowId, columnId }, focus: { rowId, columnId } }. |
| `onSelectionChange?` | `(selection: GridSelection \| null) => void` |  | Fires when the active cell or the range changes. |
| `setApi?` | `(api: EditableGridApi) => void` |  | The imperative API: focusCell(ref), startEditing(ref?), addRow(values?, index?) returning the id, deleteRows(ids), undo(), redo(), getSelection(), copySelection(), editRow(id) (opens the row form). |
| `classNames?` | `Partial<Record<EditableGridSlot, string>>` |  | Extra classes for the parts: toolbar, title, scroller, table, header, corner, columnHeader, row, rowHeader, cell, editor, selection, active, add, empty. |
| `className?` | `string` |  | Extra classes for the panel, merged last. |

## Parts

### parseClipboardGrid (function)

parseClipboardGrid(text) splits a copied block into rows of cells: tab separated from Excel and Google Sheets, or a ; / , CSV when every line has the same number of fields.

### coerceCellValue (function)

coerceCellValue(text, column, locale?) turns a pasted or typed string into the column's value ("1,234.50" to 1234.5 in en-US, a date string to a Date, an option label to its value). Text that does not parse stays text, so the cell shows it as invalid.

### DEFAULT_EDITABLE_GRID_LABELS (constant)

The English copy of the grid, menus and announcements. Spread it into labels when you translate only a few keys.

### GridColumn (type)

One column: id, header, type, width, minWidth, options, currency, decimals, min, max, step, required, validate, readOnly, align, format, placeholder and frozen.

### EditableGridApi (type)

The imperative handle setApi hands you, for toolbar buttons outside the grid: focusCell, startEditing, addRow, deleteRows, undo, redo, getSelection, copySelection and editRow.

## Examples

- **Paste from Excel**: A tab separated block lands from the active cell, adds rows and parses numbers and dates in the grid's locale.
- **2,000 rows**: Only the visible rows render; scroll and watch the frame counter.
- **Validation**: Required, min / max, options and a custom rule, with the error summary jumping to the next one.
- **Bulk delete with undo**: rowHeader="checkbox": pick rows, delete them from the toolbar, undo from the toast.
- **Timesheet**: Hour columns and a computed, read-only total through format.
- **Phone row sheet**: On phones a second tap edits the whole row in a bottom sheet (a dialog on desktop).

## See also

- [Data Table](https://wingo-ui.com/components/data-table) ([Markdown](https://wingo-ui.com/components/data-table.md))
- [Invoice line items](https://wingo-ui.com/components/invoice-line-items) ([Markdown](https://wingo-ui.com/components/invoice-line-items.md))
- [CSV Import](https://wingo-ui.com/components/csv-import) ([Markdown](https://wingo-ui.com/components/csv-import.md))
- [Spreadsheet](https://wingo-ui.com/components/spreadsheet) ([Markdown](https://wingo-ui.com/components/spreadsheet.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))

---

Canonical: https://wingo-ui.com/components/editable-grid
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
