Data display
Data gridPro
A dense, virtualized grid for large datasets: resizable and pinnable columns, keyboard navigation like a spreadsheet, inline editing with validation and a totals row.
npx prodular-ui add data-gridPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
10,000 rows, editable
Entry is pinned left and Amount right, with row numbers and a sum in the footer. Edits are validated and saved through onCellEdit.
Count10,000
Sum$120,974,155.21
10,000 entries. Click a cell and use the arrow keys; type or press Enter to edit Vendor, Category, Memo or Amount.
"use client"
import * as React from "react"
import { DataGrid, createDataGridColumnHelper, useDataGrid, type CellEditHandler } from "@/components/ui/data-grid"import { Status, type StatusVariant } from "@/components/ui/status"
type Entry = { id: string date: string vendor: string category: string account: string memo: string amount: number status: "posted" | "pending" | "flagged"}
const vendors = ["Lumen Cloud Services", "Halcyon Freight", "Brightline Payroll", "Northbeam Legal", "Orchard Lane Grocers", "Tidewater Energy", "Meridian Freight", "Kestrel Health", "Fable & Finch", "Ironclad Security"]const categories = [ { value: "cloud", label: "Cloud infrastructure" }, { value: "freight", label: "Freight" }, { value: "payroll", label: "Payroll" }, { value: "legal", label: "Legal" }, { value: "office", label: "Office" }, { value: "utilities", label: "Utilities" }, { value: "insurance", label: "Insurance" },]const accounts = ["6100 Hosting", "6200 Logistics", "6300 Salaries", "6400 Professional fees", "6500 Supplies", "6600 Utilities"]const statuses: Entry["status"][] = ["posted", "posted", "posted", "pending", "posted", "flagged", "posted", "pending"]
// Deterministic, so the server and the browser render the same 10,000 rows.function makeLedger(): Entry[] { let seed = 7 const rand = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646 return Array.from({ length: 10000 }, (_, i) => { const v = Math.floor(rand() * vendors.length) const day = new Date(Date.UTC(2026, 0, 1 + Math.floor((i / 10000) * 294))) return { id: `JE-${String(40001 + i)}`, date: day.toISOString().slice(0, 10), vendor: vendors[v], category: categories[v % categories.length].value, account: accounts[v % accounts.length], memo: i % 9 === 0 ? "Quarterly true-up" : "", amount: Math.round((80 + rand() * 24000) * 100) / 100, status: statuses[Math.floor(rand() * statuses.length)], } })}
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })const statusMeta: Record<Entry["status"], { label: string; variant: StatusVariant }> = { posted: { label: "Posted", variant: "success" }, pending: { label: "Pending", variant: "warning" }, flagged: { label: "Flagged", variant: "destructive" },}
const helper = createDataGridColumnHelper<Entry>()const columns = helper.columns([ helper.accessor("id", { header: "Entry", size: 112, enableHiding: false, meta: { aggregate: "count" }, cell: ({ getValue }) => <span className="font-mono text-[13px] text-muted-foreground">{getValue()}</span>, }), helper.accessor("date", { header: "Date", size: 128, sortFn: "alphanumeric", cell: ({ getValue }) => date.format(new Date(getValue())), }), helper.accessor("vendor", { header: "Vendor", size: 200, meta: { editor: "text" } }), helper.accessor("category", { header: "Category", size: 176, meta: { editor: "select", options: categories }, cell: ({ getValue }) => categories.find((c) => c.value === getValue())?.label, }), helper.accessor("account", { header: "GL account", size: 176, cell: ({ getValue }) => <span className="text-muted-foreground">{getValue()}</span> }), helper.accessor("memo", { header: "Memo", size: 220, meta: { editor: "text" }, cell: ({ getValue }) => getValue() || <span className="text-muted-foreground/60">Add a memo</span>, }), helper.accessor("status", { header: "Status", size: 120, cell: ({ getValue }) => ( <Status variant={statusMeta[getValue()].variant} appearance="pill"> {statusMeta[getValue()].label} </Status> ), }), helper.accessor("amount", { header: "Amount", size: 176, meta: { align: "end", editor: "number", aggregate: "sum", formatAggregate: (v) => usd.format(v) }, cell: ({ getValue }) => <span className="font-medium">{usd.format(getValue())}</span>, }),])
export default function DataGridLedger() { const [data, setData] = React.useState(makeLedger) const table = useDataGrid({ data, columns, getRowId: (row) => row.id, initialState: { columnPinning: { start: ["id"], end: ["amount"] } }, })
const onCellEdit: CellEditHandler<Entry> = async ({ rowId, columnId, value }) => { if (columnId === "amount") { if (typeof value !== "number" || Number.isNaN(value)) return "Enter an amount, like 1,250.00" if (value <= 0) return "Amounts must be above zero. Book refunds as a credit entry." } if (columnId === "vendor" && !String(value).trim()) return "Every entry needs a vendor." // A real app would save here. The short wait shows the saving state. await new Promise((r) => setTimeout(r, 250)) setData((rows) => rows.map((r) => (r.id === rowId ? { ...r, [columnId]: value } : r))) }
return ( <div className="flex w-full flex-col gap-2"> <DataGrid table={table} height={440} rowNumbers onCellEdit={onCellEdit} aria-label="General ledger" /> <p className="px-1 text-[13px] text-muted-foreground"> 10,000 entries. Click a cell and use the arrow keys; type or press Enter to edit Vendor, Category, Memo or Amount. </p> </div> )}No rows
The header stays in place and empty explains what to do next.
No products match. Clear the search or import a CSV.
"use client"
import { DataGrid, createDataGridColumnHelper, useDataGrid } from "@/components/ui/data-grid"
type Row = { sku: string; product: string; stock: number }
const helper = createDataGridColumnHelper<Row>()const columns = helper.columns([ helper.accessor("sku", { header: "SKU", size: 120 }), helper.accessor("product", { header: "Product", size: 240 }), helper.accessor("stock", { header: "In stock", size: 120, meta: { align: "end" } }),])
export default function DataGridEmpty() { const table = useDataGrid({ data: [] as Row[], columns }) return ( <DataGrid table={table} height={220} className="w-full" empty={ <span> No products match. <span className="text-foreground">Clear the search</span> or import a CSV. </span> } /> )}Anatomy
const table = useDataGrid({ data, columns, initialState: { columnPinning: { start: ["id"], end: [] } } })
<DataGrid table={table} height={480} onCellEdit={save} rowNumbers />Guidance
Use it when
- Thousands of rows that people scan, compare and edit in place: ledgers, inventories, price lists, bulk data cleanup.
- Spreadsheet-style work where the keyboard matters more than the mouse.
- Pinning the identifying column at the start and the key number at the end, so both stay visible while scrolling.
Avoid
- Lists under a few hundred rows with row actions, filters and bulk selection. Use Data table.
- Editing that needs more than one field at a time. Open a sheet or dialog for the record.
- Rich content in cells, like long text or images. Rows have a fixed height.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Define columns with createDataGridColumnHelper and give each a size. Numbers get meta.align "end".
- Make a column editable with meta.editor: "text", "number" or "select" (with meta.options), and pass onCellEdit to DataGrid.
- In onCellEdit, validate and return a message string to reject the value; the editor stays open with the message. Otherwise save, then update your data immutably.
- Pin columns with initialState.columnPinning { start: [...], end: [...] }. People can pin, sort and hide from each header menu.
- Add totals with meta.aggregate (sum, avg, count, min, max) and meta.formatAggregate for currency.
- Set height on DataGrid; only visible rows render, so keep data as a stable array and update it immutably.
- Keyboard: arrows, Page Up and Down, Home and End move; Enter or F2 edits; typing replaces; Escape cancels; Enter saves and moves down; Tab saves and moves right; Cmd or Ctrl+C copies the cell.
API
useDataGrid
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { data, columns, getRowId?, initialState? } | Creates the table with sizing, resizing, pinning, visibility and sorting. |
DataGrid
| Prop | Type | Default | Description |
|---|---|---|---|
| table | DataGridInstance | The table from useDataGrid. | |
| height | number | string | 480 | Height of the scroll area. |
| rowHeight | number | 36 | Fixed row height in pixels. |
| onCellEdit | ({ row, rowId, columnId, value }) => void | string | Promise<void | string> | Saves an edit. Return a string to reject it. | |
| rowNumbers | boolean | false | A sticky row numbers column. |
| empty | ReactNode | Shown when there are no rows. |
Column meta
| Prop | Type | Default | Description |
|---|---|---|---|
| editor / options / aggregate / align | DataGridColumnMeta | Per column editing, totals and alignment. |