Editable Grid React component for Tailwind CSS v4
v1.0.1Updated in 1.7.0
A React editable grid that works like a spreadsheet: typed cells edited in place, range selection, Excel copy and paste, undo, validation.
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.
Editable Grid is a Pro React component in the React table and list components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add editable-grid, together with Button, Checkbox, Currency Input, Date Picker, Drawer and 16 more and the npm packages @tanstack/react-virtual, motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add editable-gridEditable Grid is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import EditableGrid from @/components/ui/editable-grid.
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
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.
valueR[]The rows, controlled. Every row needs a unique string id. Pair with onValueChange.
defaultValuedefault"[]"R[]The starting rows while uncontrolled.
onValueChange(rows: R[]) => voidEvery 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.
createRowdefault"() => ({ id: crypto.randomUUID() })"(index: number) => RDefaults for new rows (the add row button, inserts, rows a paste appends). An id is generated when it returns none.
titleReactNodeThe toolbar's title, e.g. the sheet name.
toolbarActionsReactNodeYour own buttons at the end of the toolbar (export, import, filters).
emptyStatedefault"an EmptyState with Add the first row"ReactNodeShown under the headers when there are no rows.
labelsdefault"DEFAULT_EDITABLE_GRID_LABELS"Partial<EditableGridLabels>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.
densitydefault"default""compact" | "default"Row height 32 / 40px (40 / 44px on touch screens) and cell text 13 / 14px.
rowHeaderdefault"number"EditableGridRowHeadernumber, 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).
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The active cell outline, the range and the checkboxes.
colordefault""stringAny CSS color for the outline, the range and the checkboxes. Replaces the tone.
radiusdefault"md""none" | "sm" | "md" | "lg"Corner radius of the panel: 0 / 12 / 16 / 24px.
showToolbardefaulttruebooleanThe 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.
showErrorsdefault"both""cell" | "summary" | "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.
heightdefault480numberMax height of the scroller in px; the header sticks inside it. 0 grows with the rows and never virtualizes.
frozenColumnsdefault1numberColumns that stay at the start edge while you scroll sideways (next to the row header), with a shadow once scrolled.
resizableColumnsdefaulttruebooleanDrag a header's end edge to resize a column; double-click it to fit the content.
allowAddRowsdefaulttruebooleanThe Add row button and row, inserts from the row menu, and Enter on the last cell adding a row.
allowDeleteRowsdefaulttruebooleanDelete in the row menu and the row sheet, and bulk delete in the toolbar.
allowPasteNewRowsdefaulttruebooleanA paste taller than the rows left appends rows; off, the extra lines are dropped.
validateOndefault"change""change" | "blur"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.
readOnlydefaultfalsebooleanSelection and copy only: no editing, paste, add or delete. On phones the row sheet shows the row read-only with a Close button.
responsivedefaulttruebooleanBelow 768px a second tap edits the whole row in a bottom sheet and the row menu is an action sheet.
localedefault""stringNumbers, 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).
virtualThresholddefault100numberAbove this many rows only the visible ones render (fixed row height). Needs a height; 0 always virtualizes.
rowReadOnly(row: R) => booleanRows that can be selected and copied but not edited (a locked invoice line, an approved timesheet).
defaultSelectionGridSelectionThe selection on mount: { anchor: { rowId, columnId }, focus: { rowId, columnId } }.
onSelectionChange(selection: GridSelection | null) => voidFires when the active cell or the range changes.
setApi(api: EditableGridApi) => voidThe imperative API: focusCell(ref), startEditing(ref?), addRow(values?, index?) returning the id, deleteRows(ids), undo(), redo(), getSelection(), copySelection(), editRow(id) (opens the row form).
classNamesPartial<Record<EditableGridSlot, string>>Extra classes for the parts: toolbar, title, scroller, table, header, corner, columnHeader, row, rowHeader, cell, editor, selection, active, add, empty.
classNamestringExtra classes for the panel, merged last.
Parts
parseClipboardGrid
functionparseClipboardGrid(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
functioncoerceCellValue(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
constantThe English copy of the grid, menus and announcements. Spread it into labels when you translate only a few keys.
GridColumn
typeOne column: id, header, type, width, minWidth, options, currency, decimals, min, max, step, required, validate, readOnly, align, format, placeholder and frozen.
EditableGridApi
typeThe 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).
Dependencies
Files
- components/ui/editable-grid.tsx
FAQ
Is the React Editable Grid component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Editable Grid?
Run npx wingo-ui@latest add editable-grid. It also adds the Wingo UI items it builds on (Button, Checkbox, Currency Input, Date Picker, Drawer and 16 more) and the npm packages @tanstack/react-virtual, motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/editable-grid.
How does Editable Grid behave on phones?
Below 768px a second tap edits the whole row in a bottom sheet and the row menu is an action sheet. The responsive prop controls this and is on by default.
See also
More React table and list components
Articles using this component
- React Editable Table: Spreadsheet Grid or Inline Edit
Component guides9 min read