UILab

Data Grid

High-performance editable data grid with virtualization, spreadsheet-like keyboard navigation and cell editing.

Add row

Introduction

Data Grid is a spreadsheet-like grid built on TanStack Table and TanStack Virtual. Rows and columns are virtualized, so 10,000+ rows stay smooth. All primitives (popover, select, command, calendar…) come from uilab-core.

Use Data Table for read-mostly, paginated lists. Use Data Grid when users need to edit data in place.

Installation

Install via following command:

Reminder

Following CLI command will install @tanstack/react-table, @tanstack/react-virtual, @dnd-kit/*, cmdk and sonner to your project.

npx shadcn add @uilab-core/data-grid

Make sure the Toaster from Sonner is mounted. The grid uses it for copy/paste and validation feedback.

The block installs the following files:

components/data-grid/   # grid, cells, header, menus, skeleton...
hooks/                  # use-data-grid, use-data-grid-undo-redo, ...
lib/                    # data-grid-types, data-grid-filters, ...
components/ui/          # faceted, sortable

Usage

"use client"

import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { DataGrid } from "@/components/data-grid/data-grid"
import { DataGridKeyboardShortcuts } from "@/components/data-grid/data-grid-keyboard-shortcuts"
import { useDataGrid } from "@/hooks/use-data-grid"
import type { DataGridFeatures } from "@/lib/data-grid-features"

type Person = { id: string; name?: string; age?: number }

const columns: ColumnDef<DataGridFeatures, Person>[] = [
  {
    id: "name",
    accessorKey: "name",
    header: "Name",
    meta: { cell: { variant: "short-text" } },
  },
  {
    id: "age",
    accessorKey: "age",
    header: "Age",
    meta: { cell: { variant: "number", min: 0 } },
  },
]

export function PeopleGrid({ initialData }: { initialData: Person[] }) {
  const [data, setData] = React.useState(initialData)

  const { table, ...dataGridProps } = useDataGrid({
    data,
    columns,
    onDataChange: setData,
    getRowId: (row) => row.id,
  })

  return (
    <>
      <DataGridKeyboardShortcuts />
      <DataGrid table={table} {...dataGridProps} />
    </>
  )
}

Performance

Define columns outside the component (or memoize them). A new columns array on every render forces the whole grid to rebuild.

Examples

Read only

Pass readOnly to disable every edit, paste and delete action. stretchColumns makes columns fill the available width.

Loading

Render DataGridSkeleton while data is loading, e.g. as a Suspense fallback.

Toolbar

Filter, sort, row height and column visibility menus all take the table instance.

<div role="toolbar" aria-orientation="horizontal" className="flex items-center gap-2 self-end">
  <DataGridFilterMenu table={table} />
  <DataGridSortMenu table={table} />
  <DataGridRowHeightMenu table={table} />
  <DataGridViewMenu table={table} />
</div>

Filtering only works on columns that have a filterFn. Set meta.label to control the name shown in the menus.

import { getFilterFn } from "@/lib/data-grid-filters"

const filterFn = getFilterFn<Person>()

const columns: ColumnDef<DataGridFeatures, Person>[] = [
  {
    id: "name",
    accessorKey: "name",
    header: "Name",
    filterFn,
    meta: { label: "Name", cell: { variant: "short-text" } },
  },
]

Row selection

import { getDataGridSelectColumn } from "@/components/data-grid/data-grid-select-column"

const columns = [
  getDataGridSelectColumn<Person>({ enableRowMarkers: true }),
  ...personColumns,
]

Adding and deleting rows

onRowAdd can return the cell to focus after the row is created. onRowsAdd is used by paste when it needs to grow the grid. This is faster than adding rows one at a time.

const { table, ...dataGridProps } = useDataGrid({
  data,
  columns,
  onDataChange: setData,
  getRowId: (row) => row.id,
  onRowAdd: () => {
    const row = { id: crypto.randomUUID() }
    setData((prev) => [...prev, row])
    return { rowId: row.id, columnId: "name" }
  },
  onRowsAdd: (count) => {
    setData((prev) => [
      ...prev,
      ...Array.from({ length: count }, () => ({ id: crypto.randomUUID() })),
    ])
  },
  onRowsDelete: (rows) => {
    const removed = new Set(rows)
    setData((prev) => prev.filter((row) => !removed.has(row)))
  },
})

Search and paste

useDataGrid({
  data,
  columns,
  enableSearch: true, // Ctrl/Cmd + F
  enablePaste: true,  // Ctrl/Cmd + V
})

Undo / redo

useDataGridUndoRedo keeps the history, and you report each change to it. The preview source at the top of this page has a full example.

import { useDataGridUndoRedo } from "@/hooks/use-data-grid-undo-redo"

const { trackCellsUpdate, trackRowsAdd, trackRowsDelete } = useDataGridUndoRedo({
  data,
  onDataChange: setData,
  getRowId: (row) => row.id,
})

Column pinning

useDataGrid({
  data,
  columns,
  initialState: {
    columnPinning: { start: ["select", "name"], end: ["actions"] },
  },
})

RTL

Wrap the grid in Base UI's DirectionProvider.

import { DirectionProvider } from "@base-ui/react/direction-provider"

<DirectionProvider direction="rtl">
  <PeopleGrid />
</DirectionProvider>

Cell variants

Set the variant with meta.cell.variant on the column.

VariantOptionsDescription
short-text-Single-line inline text
long-text-Multi-line text edited in a popover
numbermin, max, stepNumeric input
url-Validated, clickable link
checkbox-Boolean value
selectoptions: { label, value }[]Single choice
multi-selectoptions: { label, value }[]Multiple choices rendered as badges
date-Date picker using Calendar
filemaxFileSize, maxFiles, accept, multipleFile upload, requires onFilesUpload and onFilesDelete
useDataGrid({
  data,
  columns,
  onFilesUpload: async ({ files, rowId, columnId }) => {
    const uploaded = await uploadFiles(files)
    return uploaded.map((f) => ({ id: f.id, name: f.name, size: f.size, type: f.type, url: f.url }))
  },
  onFilesDelete: async ({ fileIds }) => {
    await deleteFiles(fileIds)
  },
})

Custom variants

A cell variant receives DataGridCellProps and wraps its content in DataGridCellWrapper. The wrapper handles focus, selection, search highlighting and keyboard events.

import { DataGridCellWrapper } from "@/components/data-grid/data-grid-cell-wrapper"
import type { DataGridCellProps } from "@/lib/data-grid-types"

export function RatingCell<TData>(props: DataGridCellProps<TData>) {
  const value = props.cell.getValue() as number

  return <DataGridCellWrapper {...props}>{"★".repeat(value)}</DataGridCellWrapper>
}

API

useDataGrid

Accepts all TanStack Table options, plus the following:

PropTypeDescription
onDataChange(data: TData[]) => voidCalled after any cell edit, paste or clear
onRowAdd() => Partial<CellPosition> | nullEnables adding rows (Shift + Enter, footer button). Return the cell to focus
onRowsAdd(count: number) => voidBulk add used by paste
onRowsDelete(rows: TData[], rowIds: string[]) => voidEnables deleting rows
onPaste(updates: CellUpdate[]) => voidIntercept pasted updates
onFilesUpload / onFilesDelete(params) => PromiseRequired for file cells
enableSearchbooleanCtrl/Cmd + F search
enablePastebooleanClipboard paste
readOnlybooleanDisable editing
autoFocusboolean | Partial<CellPosition>Focus a cell on mount
overscannumberRows rendered outside the viewport

DataGrid

Takes everything returned by useDataGrid, plus:

PropTypeDefault
heightnumber600
stretchColumnsbooleanfalse

Keyboard shortcuts

Press Ctrl/Cmd + / inside the grid to see every shortcut.

KeysAction
↑ ↓ ← →, Tab, Shift + TabMove between cells
Home / EndFirst / last column
Ctrl/Cmd + Home / EndFirst / last cell
PgUp / PgDnMove one page
Shift + ArrowExtend selection
Ctrl/Cmd + ASelect all cells
Enter, F2, double clickEdit cell
EscapeExit edit mode / clear selection
Shift + EnterInsert row below
Ctrl/Cmd + C / X / VCopy / cut / paste
Delete, BackspaceClear selected cells
Ctrl/Cmd + BackspaceDelete selected rows
Ctrl/Cmd + Z / Ctrl/Cmd + Shift + ZUndo / redo
Ctrl/Cmd + FSearch
Ctrl/Cmd + Shift + F / SOpen filter / sort menu

Credits

Ported from Dice UI Data Grid and adapted to use uilab-core primitives.

On this page