Your first grid in 5 minutes
You have installed the grid. Here is the shortest path to one that looks like it belongs in your app, with the toolbar, pager, selection and editing on.
-
Describe a row. The grid is typed from this.
customers/columns.tsx export type Customer = {id: stringname: stringemail: stringplan: "free" | "pro" | "team"mrr: numberactive: booleanjoinedAt: Date} -
Build the columns.
createDataGridColumns<Customer>()gives you one builder per column kind. Keys are checked againstCustomer; each kind brings the right sort, filter, editor, aggregation and format.customers/columns.tsx import { createDataGridColumns } from "@/components/ui/data-grid"const col = createDataGridColumns<Customer>()export const columns = col.columns([col.select(),col.text("name", { header: "Name", editable: true }),col.link("email", { header: "Email", href: (v) => `mailto:${v}` }),col.enum("plan", {header: "Plan",options: [{ value: "free", label: "Free" },{ value: "pro", label: "Pro" },{ value: "team", label: "Team" },],editable: true,}),col.currency("mrr", { header: "MRR", currency: "USD" }),col.boolean("active", { header: "Active" }),col.date("joinedAt", { header: "Joined" }),]) -
Render it.
dataandcolumnsare all it needs;getRowIdkeeps selection and editing stable when rows move.customers/page.tsx import { DataGrid } from "@/components/ui/data-grid"import { columns, type Customer } from "./columns"export function CustomersPage({ customers }: { customers: Customer[] }) {return <DataGrid data={customers} columns={columns} getRowId={(c) => c.id} />} -
Save edits.
onCellEditfires after a cell is committed, with the row, the column id and the typed value. Throw (or return a rejected promise) to keep the editor open with the message.customers/page.tsx <DataGriddata={customers}columns={columns}onCellEdit={async ({ row, columnId, value }) => {await api.customers.update(row.id, { [columnId]: value })// then refetch, or update local state}}/> -
Act on a selection. Whatever you render in
bulkActionsappears in a bar while rows are selected, with the selected rows as the argument.customers/page.tsx <DataGriddata={customers}columns={columns}bulkActions={(rows) => (<Button variant="destructive" size="sm" onClick={() => archive(rows.map((r) => r.id))}>Archive {rows.length}</Button>)}/>
That is the whole thing. Here it is with every feature on, on demo data:
| 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> </> )} /> );}What to read next
Section titled “What to read next”- Columns: every kind, every option, custom cells and headers.
- Server-side data when the rows live behind an API.
- Composition when the default layout isn’t yours.
- Theming to make it look like your product (it already mostly does).