Overview
The data grid is a React component for the screens where people spend the day with real data: sorting, faceted filters, fuzzy search, grouping with aggregation, selection with bulk actions, inline editing, spreadsheet-style keyboard ranges and clipboard, column pinning, reordering and resizing, virtualised rows, export, saved views, and a toolbar and pager already wired to all of it. It is built on TanStack Table v9 and the shadcn/ui primitives you already have, and it is installed as source into your project, so nothing sits between you and any of it.
| Yuki Khan | yuki.khan@mail.test | Churned | Team | Growth | Director | $0.00 | 21 | 74.6% | Sep 2, 2023 | May 5, 2025, 10:52 PM | nps-9 | Japan | — | |||
| Mateo Rossi | mateo.rossi@corp.example | Invited | Free | Platform | PM | $0.00 | 1 | 6.2% | Oct 21, 2024 | Jul 7, 2026, 9:33 PM | — | Germany | Renewal in Q1. | |||
| Aarav Sharma | aarav.sharma@example.com | Churned | Pro | Support | Lead | $0.00 | 2 | 24.5% | Feb 6, 2026 | May 4, 2026, 8:56 PM | — | France | — | |||
| Vihaan Tanaka | vihaan.tanaka@corp.example | Churned | Pro | Finance | Manager | $0.00 | 2 | 92.8% | Oct 14, 2023 | Aug 27, 2024, 3:51 AM | — | India | Renewal in Q1. | |||
| Mia Menon | mia.menon@example.com | Active | Free | Finance | Director | $0.00 | 1 | 27.8% | May 19, 2024 | Jul 27, 2025, 2:01 PM | nps-9 | Canada | — | |||
| Mateo Nguyen | mateo.nguyen@corp.example | Active | Enterprise | Design | Manager | $2,449.00 | 31 | 29.5% | Feb 27, 2025 | Sep 15, 2026, 11:14 AM | apac | Brazil | Speaks at meetups. | |||
| Theo Silva | theo.silva@team.dev | Active | Pro | Research | Engineer | $36.00 | 3 | 47.5% | Feb 19, 2024 | May 29, 2024, 11:16 AM | — | Australia | — | |||
| Theo Sharma | theo.sharma@example.com | Paused | Team | Finance | Director | $406.00 | 14 | 32.6% | Nov 9, 2023 | Feb 2, 2025, 6:06 PM | apac | Canada | — | |||
| Diya Silva | diya.silva@corp.example | Active | Free | Data | Designer | $0.00 | 1 | 4.7% | Oct 29, 2025 | Jun 23, 2026, 7:48 AM | churn-riskeu | Canada | Asked about invoices. | |||
| Saanvi Patel | saanvi.patel@corp.example | Active | Free | Design | Analyst | $0.00 | 1 | 43.4% | Oct 23, 2026 | Oct 31, 2026, 5:04 AM | vip | Brazil | — |
import * as React from "react";import { MoreHorizontalIcon } from "lucide-react";
import { Button } from "@/components/ui/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu";import { DataGrid, createDataGridColumns, useDataGrid } from "@/components/ui/data-grid";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
const col = createDataGridColumns<Person>();
const columns = col.columns([ col.select(), col.text("name", { header: "Name", editable: true, size: 200 }), col.link("email", { header: "Email", size: 240, href: (v) => `mailto:${v}` }), col.enum("status", { header: "Status", options: STATUS_OPTIONS, editable: true, size: 120 }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS, size: 120 }), col.text("team", { header: "Team", size: 120 }), col.text("role", { header: "Role", size: 120 }), col.currency("mrr", { header: "MRR", currency: "USD", editable: { min: 0 }, size: 120 }), col.number("seats", { header: "Seats", editable: { min: 1, step: 1 }, size: 90 }), col.percent("usage", { header: "Usage", size: 100 }), col.boolean("active", { header: "Active", editable: true, size: 90 }), col.date("joinedAt", { header: "Joined", size: 130 }), col.datetime("lastSeenAt", { header: "Last seen", size: 180 }), col.tags("tags", { header: "Tags", size: 200 }), col.text("country", { header: "Country", size: 130 }), col.text("notes", { header: "Notes", editable: { type: "textarea" }, size: 220, filter: "text" }), col.actions(({ row }) => ( <DropdownMenu modal={false}> <DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Row actions" />} > <MoreHorizontalIcon /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => navigator.clipboard?.writeText(row.original.id)}> Copy ID </DropdownMenuItem> <DropdownMenuItem onClick={() => row.pin(row.getIsPinned() ? false : "top")}> {row.getIsPinned() ? "Unpin row" : "Pin row to top"} </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive">Delete</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> )),]);
export default function DataGridDemo({ rows = 500 }: { rows?: number }) { const [data, setData] = React.useState(() => makePeople(rows)); const grid = useDataGrid<Person>({ name: "demo", data, columns, getRowId: (row) => row.id, persist: "kadal-ui-demo", rowPinning: true, onCellEdit: ({ rowId, columnId, value }) => setData((prev) => prev.map((p) => (p.id === rowId ? { ...p, [columnId]: value } : p))), }); // The dev page and the e2e tests reach the api through here. if (import.meta.env.DEV && typeof window !== "undefined") { (window as unknown as { __grid: unknown }).__grid = grid; } return ( <DataGrid grid={grid} height={560} bulkActions={(selected) => ( <> <Button variant="outline" size="sm" onClick={() => alert(`Emailing ${selected.length}`)}> Email </Button> <Button variant="destructive" size="sm" onClick={() => setData((prev) => prev.filter((p) => !selected.includes(p)))} > Delete </Button> </> )} /> );}Features
Section titled “Features”One page per feature, each with a live example and its code.
| Feature | What it does |
|---|---|
| Columns | A typed builder with one call per kind; each kind brings its cell, sort, filter, editor, aggregation and format. |
| Sorting | Click a header; shift-click to add a second key; custom sort functions. |
| Filtering | Facet, text, number range, date range and boolean filters from the header, with chips in the toolbar. |
| Global search | One box that fuzzy-matches across every searchable column. |
| Pagination | Page size, page position, go-to, first/last, the selected count. |
| Row selection and bulk actions | Checkboxes, shift-click ranges, select the page, a bar with your actions. |
| Row actions | A menu per row, row click, row tinting. |
| Column visibility | The View popover, hide from the header menu, start hidden. |
| Column pinning | Pin columns to the start or end; they stay put while the rest scroll. |
| Column reordering | Drag a header, or move left and right from its menu. |
| Column resizing | Drag the edge; double-click to fit to content. |
| Expanding and tree data | Nested rows from getSubRows, with an expander column. |
| Grouping and aggregation | Group by any column; sums, means, counts and ranges in the group rows. |
| Virtualization | Only the rows in view are in the DOM, automatically past 200 rows. |
| Inline editing | Text, number, select, checkbox, date and textarea editors; validation; onCellEdit. |
| Cell selection and clipboard | Focus a cell, drag or shift-arrow a range, copy it as TSV. |
| Keyboard navigation | Arrows, Home/End, PageUp/PageDown, Enter, Space, Escape. |
| Export | CSV, JSON and clipboard, formatted or raw, selected rows only. |
| Density | Compact, comfortable or spacious rows. |
| Row pinning | Keep a row at the top or bottom while you scroll and page. |
| Loading and empty states | A progress bar, a spinner, an empty state and an error state with retry. |
| Saved views and persistence | Remember the user’s view in storage; let them name and switch between views. |
| URL state | Filters, search, sort and page in the address bar. |
| Server-side data | The server pages, sorts and filters; the grid renders what it is given. |
| Accessibility | A grid role, aria-sort, live announcements, focus that stays where you left it. |
| Internationalisation | Every string in one labels object; numbers and dates through Intl. |
| Composition and custom layouts | Place the parts yourself inside DataGridRoot. |
| API reference | Every option, prop and type. |
Anatomy
Section titled “Anatomy”<DataGrid> renders these parts, top to bottom. Each is a component you can also render yourself,
and each has a data-slot attribute you can style by.
DataGridRoot(data-slot="data-grid"): the provider and frame. Holds the grid api, the fullscreen state, the loading and error state, and the live region that announces changes.DataGridToolbar(data-grid-toolbar): on the left,DataGridSearch, thenDataGridFilterChips(one chip per active column filter) andDataGridGroupingBar(one chip per grouped column). On the right, yourtoolbarActions, thenDataGridViewsMenu(withpersist),DataGridGroupMenu,DataGridViewOptions,DataGridDensityMenu,DataGridExportMenuandDataGridFullscreenToggle.DataGridBulkActions(data-grid-bulk-actions): appears while rows are selected, with the count, “Clear selection” and whatever you return frombulkActions.DataGridTable(data-grid-viewportarounddata-grid-table): the scroll area and the table. Headers areDataGridColumnHeader(title, sort and filter indicators, the column menu, the filter popover, the resize handle). Cells render by column kind, or with yourcell.DataGridPagination(data-grid-pagination): the selected count or row count, rows per page, “Page x of y”, a go-to box past five pages, and first/previous/next/last.
Two ways to use it
Section titled “Two ways to use it”All in one
Section titled “All in one”<DataGrid> takes the options of useDataGrid plus the presentation props and renders every part
above. This is the one to start with.
import { DataGrid, createDataGridColumns } from "@/components/ui/data-grid"
const col = createDataGridColumns<Customer>()const columns = col.columns([ col.select(), col.text("name", { header: "Name" }), col.currency("mrr", { header: "MRR", currency: "USD" }),])
export function Customers({ customers }: { customers: Customer[] }) { return ( <DataGrid data={customers} columns={columns} getRowId={(c) => c.id} height={560} bulkActions={(rows) => <Button size="sm">Archive {rows.length}</Button>} /> )}The hook and the parts
Section titled “The hook and the parts”useDataGrid builds the table and returns the api (grid.table is the TanStack table). Render the
parts yourself inside DataGridRoot, in any order, with your own controls between them. Pass the
same grid to <DataGrid grid={grid}> when you want the default layout but need the api outside
the component too.
import { DataGridFilterChips, DataGridPagination, DataGridRoot, DataGridSearch, DataGridTable, DataGridViewOptions, useDataGrid,} from "@/components/ui/data-grid"
export function Customers({ customers }: { customers: Customer[] }) { const grid = useDataGrid<Customer>({ key: "customers", data: customers, columns, getRowId: (c) => c.id }) return ( <DataGridRoot grid={grid}> <div className="flex items-center gap-2"> <h2 className="font-semibold">Customers</h2> <DataGridSearch /> <DataGridFilterChips /> <DataGridViewOptions className="ml-auto" /> <Button size="sm">New customer</Button> </div> <DataGridTable height={480} /> <DataGridPagination /> </DataGridRoot> )}Composition covers the parts in detail.
What is on by default
Section titled “What is on by default”The grid turns on what a console page usually wants. Each default has an option to turn it off.
| Default | What you get | Turn it off |
|---|---|---|
| Row selection | col.select() renders checkboxes; shift-click selects a range; the header ticks the page. |
selection={false}, or selection={{ mode: "single" }} for one row at a time. |
| Cell selection | Click a cell to focus it; drag or shift-arrow a range; ⌘C copies it. | cellSelection={false}. Rows then toggle selection on click when there is no checkbox column. |
| Grouping | “Group by” in each header menu, a Group menu in the toolbar, aggregated group rows. | grouping={false}; groupable: false on one column. |
| Fuzzy search | The toolbar search box matches by rank across every searchable column. | globalFilter="includesString" for plain substring matching, or globalFilter={false} to remove the box. |
| Pagination, 10 rows | A pager with 10, 20, 50 and 100 as the choices. | pagination={false} shows every row; pagination={{ pageSize: 25, pageSizeOptions: [25, 50] }} changes the numbers. |
| Virtualisation past 200 rows | Once the row model holds 200 rows or more, only the rows in view render. Needs a height or fillHeight. |
virtualize={false}; virtualize for always; virtualize={{ threshold: 500 }} to move the line. |
Editing, when a column is editable |
Enter, F2 or double-click opens the editor for that kind. | editing={false}. |
| Toolbar and pager | Both render with every built-in control. | toolbar={false}, toolbar={{ export: false }}, pager={false}. |
| Column hiding, pinning, resizing, reordering | All on for every data column. | Per column: enableHiding, enablePinning, enableResizing, draggable; grid-wide through table={{ enableColumnResizing: false }}. |
Off by default: rowPinning, persist, server, striped, fillHeight.
What it is built on
Section titled “What it is built on”TanStack Table v9 is the headless core (row models, state in a store, feature-typed APIs). shadcn/ui on Base UI supplies the menus, popovers, selects, checkboxes and buttons. TanStack Virtual renders only the rows in view, dnd kit moves the headers, and Tailwind CSS v4 styles it all through your theme tokens. The introduction has the table and the links.
- API reference: every option of
useDataGrid, every prop of<DataGrid>and the parts, every exported type. - Type safety: how the column builder,
DataGridColumnDef<T>and TanStack v9’s feature typing keep a grid typed end to end. - Your first grid in 5 minutes if you have not built one yet.