A fast, accessible data grid for thousands of rows: sorting, filtering, grouping, editing, copy and paste, export and server-side data. Columns are TanStack Table column definitions.
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
export default function DataGridBasicExample() {
return (
<DataGrid label="People" data={people} columns={columns} getRowId={(p) => p.id} height={320} />
);
}Installation
npx rdloom add data-gridCopies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs @tanstack/react-table, @tanstack/react-virtual, react-aria-components; add --install to install them.
Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/data-grid.jsonWorks, but without upgrade tracking.
Usage
import { DataGrid } from "@rdloom/react";
<DataGrid label="People" data={people} columns={columns} getRowId={(p) => p.id} height={320} />Selection
0 selected
import { useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
export default function DataGridSelectionExample() {
const [selected, setSelected] = useState<string[]>([]);
return (
<div className="flex min-w-0 max-w-full flex-col gap-2">
<p className="text-sm" aria-live="polite">
{selected.length} selected
</p>
<DataGrid
label="People"
data={people}
columns={columns}
getRowId={(p) => p.id}
height={320}
selectionMode="multiple"
selectedRowIds={selected}
onSelectionChange={setSelected}
/>
</div>
);
}Large dataset
import { useMemo } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
export default function DataGridLargeDatasetExample() {
// Only the rows in view are in the page, however many there are.
const rows = useMemo(
() => Array.from({ length: 100_000 }, (_, i) => ({ ...people[i % people.length], id: `r${i}`, name: `Person ${i + 1}` })),
[],
);
return (
<DataGrid label="100,000 rows" data={rows} columns={columns} getRowId={(p) => p.id} height={360} />
);
}Pinned
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
export default function DataGridPinnedExample() {
return (
<div className="min-w-0 max-w-md">
<DataGrid label="People" data={people} columns={columns} getRowId={(p) => p.id} height={320} pinnedColumns={["name"]} />
</div>
);
}Filter
import { useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid, TextField } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
export default function DataGridFilterExample() {
const [query, setQuery] = useState("");
return (
<div className="flex min-w-0 max-w-full flex-col gap-3">
<TextField className="w-64" label="Search" value={query} onChange={setQuery} />
<DataGrid label="People" data={people} columns={columns} getRowId={(p) => p.id} height={320} globalFilter={query} emptyMessage="Nobody matches." />
</div>
);
}Column filters
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
// meta.filter picks the filter control; false hides it.
const filterColumns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130, meta: { filter: "select" } },
{ accessorKey: "city", header: "City", size: 130, meta: { filter: "select" } },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end", filter: false } },
];
export default function DataGridColumnFiltersExample() {
return (
<DataGrid label="People" data={people} columns={filterColumns} getRowId={(p) => p.id} height={360} showColumnFilters />
);
}Editing
import { useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
// Enter, F2, double-click or typing starts an edit.
const editColumns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160, meta: { editable: true } },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end", editable: true, editor: "number" } },
];
export default function DataGridEditingExample() {
const [rows, setRows] = useState(people);
return (
<DataGrid
label="People"
data={rows}
columns={editColumns}
getRowId={(p) => p.id}
height={320}
// The grid never changes your data: apply the edit yourself.
onCellEdit={(e) => setRows((all) => all.map((r) => (r.id === e.rowId ? { ...r, [e.columnId]: e.value } : r)))}
/>
);
}Pagination
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Person {
id: string;
name: string;
team: string;
city: string;
age: number;
}
const teams = ["Design", "Platform", "Growth", "Support"];
const cities = ["Pune", "London", "Tokyo", "Berlin", "Toronto"];
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i + 1}`,
name: `Person ${i + 1}`,
team: teams[i % teams.length],
city: cities[(i * 7) % cities.length],
age: 22 + ((i * 13) % 40),
}));
const columns: ColumnDef<Person, any>[] = [
{ accessorKey: "name", header: "Name", size: 160 },
{ accessorKey: "team", header: "Team", size: 130 },
{ accessorKey: "city", header: "City", size: 130 },
{ accessorKey: "age", header: "Age", size: 90, meta: { align: "end" } },
];
export default function DataGridPaginationExample() {
return (
<DataGrid label="People" data={people} columns={columns} getRowId={(p) => p.id} height={320} pageSize={10} density="compact" />
);
}Grouping
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Order {
id: string;
region: string;
rep: string;
product: string;
amount: number;
}
const regions = ["North", "South", "East", "West"];
const reps = ["Asha", "Ben", "Chen", "Dara", "Eli"];
const products = ["Starter", "Team", "Business"];
const orders: Order[] = Array.from({ length: 80 }, (_, i) => ({
id: `o${i + 1}`,
region: regions[i % regions.length],
rep: reps[(i * 3) % reps.length],
product: products[(i * 7) % products.length],
amount: 200 + ((i * 137) % 1800),
}));
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
// Group rows total a column only when it sets aggregationFn.
const columns: ColumnDef<Order, any>[] = [
{ accessorKey: "region", header: "Region", size: 170 },
{ accessorKey: "rep", header: "Rep", size: 130 },
{ accessorKey: "product", header: "Product", size: 130 },
{
accessorKey: "amount",
header: "Amount",
size: 120,
aggregationFn: "sum",
meta: { align: "end", format: (v: number) => money.format(v) },
},
];
export default function DataGridGroupingExample() {
return (
<DataGrid label="Orders by region" data={orders} columns={columns} getRowId={(o) => o.id} groupBy={["region"]} height={360} />
);
}Tree data
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Item {
id: string;
name: string;
kind: "Folder" | "File";
size: number;
children?: Item[];
}
const file = (id: string, name: string, size: number): Item => ({ id, name, kind: "File", size });
const tree: Item[] = [
{
id: "src",
name: "src",
kind: "Folder",
size: 58,
children: [
{
id: "src/components",
name: "components",
kind: "Folder",
size: 41,
children: [file("src/components/button.tsx", "button.tsx", 4), file("src/components/data-grid.tsx", "data-grid.tsx", 37)],
},
file("src/index.ts", "index.ts", 2),
file("src/styles.css", "styles.css", 15),
],
},
{ id: "docs", name: "docs", kind: "Folder", size: 12, children: [file("docs/readme.md", "readme.md", 12)] },
file("package.json", "package.json", 1),
];
const columns: ColumnDef<Item, any>[] = [
{ accessorKey: "name", header: "Name", size: 240 },
{ accessorKey: "kind", header: "Kind", size: 100 },
{ accessorKey: "size", header: "Size (KB)", size: 110, meta: { align: "end" } },
];
export default function DataGridTreeDataExample() {
return (
<DataGrid
label="Files"
data={tree}
columns={columns}
getRowId={(item) => item.id}
getSubRows={(item) => item.children}
defaultExpanded
height={320}
/>
);
}Master detail
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Customer {
id: string;
name: string;
plan: string;
orders: { id: string; date: string; total: number }[];
}
const plans = ["Starter", "Team", "Business"];
const customers: Customer[] = Array.from({ length: 30 }, (_, i) => ({
id: `c${i + 1}`,
name: `Customer ${i + 1}`,
plan: plans[i % plans.length],
orders: Array.from({ length: 1 + (i % 3) }, (_, j) => ({
id: `c${i + 1}-o${j + 1}`,
date: `2026-0${1 + ((i + j) % 9)}-1${j}`,
total: 120 + ((i * 53 + j * 31) % 900),
})),
}));
const columns: ColumnDef<Customer, any>[] = [
{ accessorKey: "name", header: "Customer", size: 200 },
{ accessorKey: "plan", header: "Plan", size: 120 },
{ id: "orderCount", accessorFn: (c) => c.orders.length, header: "Orders", size: 100, meta: { align: "end" } },
];
// Expand a row with its toggle, or Right arrow on the first cell.
export default function DataGridMasterDetailExample() {
return (
<DataGrid
label="Customers"
data={customers}
columns={columns}
getRowId={(c) => c.id}
height={360}
detailHeight={120}
renderDetail={(c) => (
<ul aria-label={`Orders for ${c.name}`} className="grid gap-1 text-sm">
{c.orders.map((o) => (
<li key={o.id} className="flex gap-4">
<span className="w-28 tabular-nums">{o.date}</span>
<span className="tabular-nums">${o.total}</span>
</li>
))}
</ul>
)}
/>
);
}Range copy export
import { useRef, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { Button, DataGrid, type DataGridApi } from "@rdloom/react";
interface Line {
id: string;
item: string;
region: string;
units: number;
price: number;
}
const items = ["Notebook", "Pen set", "Desk lamp", "Monitor stand", "Keyboard"];
const regions = ["North", "South", "East", "West"];
const lines: Line[] = Array.from({ length: 40 }, (_, i) => ({
id: `l${i + 1}`,
item: items[i % items.length],
region: regions[(i * 3) % regions.length],
units: 5 + ((i * 11) % 60),
price: 4 + ((i * 7) % 90) + 0.5,
}));
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const columns: ColumnDef<Line, any>[] = [
{ accessorKey: "item", header: "Item", size: 170, meta: { editable: true } },
{ accessorKey: "region", header: "Region", size: 120 },
{ accessorKey: "units", header: "Units", size: 100, meta: { align: "end", editable: true, editor: "number" } },
{ accessorKey: "price", header: "Price", size: 120, meta: { align: "end", editable: true, editor: "number", format: money.format } },
];
// Shift+arrows (or dragging) select a block; Ctrl+C copies it, and Ctrl+V pastes
// spreadsheet text into the editable columns. The buttons export the filtered rows.
export default function DataGridRangeCopyExportExample() {
const [rows, setRows] = useState(lines);
const api = useRef<DataGridApi>(null);
return (
<div className="flex min-w-0 max-w-full flex-col gap-3">
<div className="flex gap-2">
<Button variant="secondary" size="sm" onPress={() => api.current?.downloadCsv()}>
Download CSV
</Button>
<Button variant="secondary" size="sm" onPress={() => api.current?.downloadExcel()}>
Download Excel
</Button>
</div>
<DataGrid
label="Order lines"
data={rows}
columns={columns}
getRowId={(r) => r.id}
height={320}
showColumnFilters
apiRef={api}
// A paste changes many cells: apply them in one update.
onCellsEdit={(edits) =>
setRows((all) => {
const byId = new Map(edits.map((e) => [e.rowId + e.columnId, e]));
return all.map((r) => {
let next = r;
for (const column of ["item", "units", "price"] as const) {
const edit = byId.get(r.id + column);
if (edit) next = { ...next, [column]: edit.value };
}
return next;
});
})
}
onCellEdit={(e) => setRows((all) => all.map((r) => (r.id === e.rowId ? { ...r, [e.columnId]: e.value } : r)))}
/>
</div>
);
}Server side
import { useCallback, useRef, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid, type DataGridQuery } from "@rdloom/react";
interface Order {
id: string;
customer: string;
status: string;
total: number;
}
const customers = ["Acme", "Globex", "Initech", "Umbrella", "Hooli", "Stark", "Wayne"];
const statuses = ["Open", "Paid", "Shipped", "Refunded"];
const everyOrder: Order[] = Array.from({ length: 50_000 }, (_, i) => ({
id: `o${i + 1}`,
customer: customers[(i * 5) % customers.length],
status: statuses[(i * 3) % statuses.length],
total: 10 + ((i * 37) % 990) + 0.25,
}));
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
// Stands in for your API: filters, sorts and cuts a page, after a delay.
async function fetchOrders(query: DataGridQuery) {
await new Promise((resolve) => setTimeout(resolve, 300));
let rows = everyOrder;
for (const { id, value } of query.filters) {
// A "set" filter sends the ticked values as an array; text and select filters send a string.
rows = rows.filter((row) => {
const cell = String(row[id as keyof Order]);
return Array.isArray(value) ? value.includes(cell) : cell.toLowerCase().includes(value.toLowerCase());
});
}
if (query.sorting.length) {
const [{ id, desc }] = query.sorting;
const key = id as keyof Order;
rows = [...rows].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0) * (desc ? -1 : 1));
}
const start = query.pageIndex * query.pageSize;
return { rows: rows.slice(start, start + query.pageSize), total: rows.length };
}
const columns: ColumnDef<Order, any>[] = [
{ accessorKey: "id", header: "Order", size: 110 },
{ accessorKey: "customer", header: "Customer", size: 160, meta: { filter: "select", filterOptions: customers } },
{ accessorKey: "status", header: "Status", size: 130, meta: { filter: "set", filterOptions: statuses } },
{ accessorKey: "total", header: "Total", size: 120, meta: { align: "end", filter: false, format: money.format } },
];
// The grid reports what the user asked for; you fetch that page and hand it back.
export default function DataGridServerSideExample() {
const [rows, setRows] = useState<Order[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const latest = useRef(0);
const load = useCallback(async (query: DataGridQuery) => {
const request = ++latest.current;
setLoading(true);
const result = await fetchOrders(query);
// A slower, older request must not overwrite a newer one.
if (request !== latest.current) return;
setRows(result.rows);
setTotal(result.total);
setLoading(false);
}, []);
return (
<DataGrid
label="Orders"
serverSide
data={rows}
rowCount={total}
isLoading={loading}
onQueryChange={load}
columns={columns}
getRowId={(o) => o.id}
pageSize={20}
showColumnFilters
height={520}
/>
);
}Column menu
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Ticket {
id: string;
title: string;
status: string;
owner: string;
priority: number;
}
const statuses = ["Open", "In progress", "Blocked", "Done", "Won't fix"];
const owners = ["Ada", "Grace", "Linus", "Margaret", "Alan", "Katherine"];
const titles = ["Login fails", "Slow search", "Wrong total", "Typo in footer", "Export is empty", "Crash on save"];
const tickets: Ticket[] = Array.from({ length: 48 }, (_, i) => ({
id: `T-${100 + i}`,
title: titles[i % titles.length],
status: statuses[(i * 3) % statuses.length],
owner: owners[(i * 5) % owners.length],
priority: 1 + (i % 4),
}));
const columns: ColumnDef<Ticket, any>[] = [
{ accessorKey: "id", header: "Ticket", size: 110, meta: { filter: false } },
{ accessorKey: "title", header: "Title", size: 190 },
// "set": tick any number of values, like a spreadsheet's filter.
{ accessorKey: "status", header: "Status", size: 150, meta: { filter: "set" } },
{ accessorKey: "owner", header: "Owner", size: 140, meta: { filter: "set" } },
{ accessorKey: "priority", header: "Priority", size: 110, meta: { align: "end", filter: false } },
];
// Each header has a menu (Alt+Down on a header opens it): sort, pin, reset
// width, hide, and show hidden columns again.
export default function DataGridColumnMenuExample() {
return <DataGrid label="Tickets" data={tickets} columns={columns} getRowId={(t) => t.id} height={360} columnMenu showColumnFilters />;
}Row reorder
import { useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid, reorderRows } from "@rdloom/react";
interface Step {
id: string;
step: string;
owner: string;
}
const initial: Step[] = [
{ id: "s1", step: "Collect requirements", owner: "Ada" },
{ id: "s2", step: "Draw the first design", owner: "Grace" },
{ id: "s3", step: "Review with the team", owner: "Linus" },
{ id: "s4", step: "Build the prototype", owner: "Margaret" },
{ id: "s5", step: "Test with users", owner: "Alan" },
{ id: "s6", step: "Ship it", owner: "Katherine" },
];
const columns: ColumnDef<Step, any>[] = [
{ accessorKey: "step", header: "Step", size: 240 },
{ accessorKey: "owner", header: "Owner", size: 160 },
];
// Drag a row by its handle, or focus it and press Alt+Up / Alt+Down. The grid
// reports the move; you keep the order, here with reorderRows.
export default function DataGridRowReorderExample() {
const [steps, setSteps] = useState(initial);
return (
<DataGrid
label="Plan"
data={steps}
columns={columns}
getRowId={(s) => s.id}
height={320}
rowReorder
onRowReorder={(move) => setSteps((rows) => reorderRows(rows, move))}
/>
);
}Fill
import { useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataGrid } from "@rdloom/react";
interface Week {
id: string;
week: string;
starts: string;
visits: number;
note: string;
}
const weeks: Week[] = [
{ id: "w1", week: "Week 1", starts: "2026-01-05", visits: 120, note: "Launch" },
{ id: "w2", week: "Week 2", starts: "2026-01-12", visits: 150, note: "" },
...Array.from({ length: 10 }, (_, i) => ({ id: `w${i + 3}`, week: "", starts: "", visits: 0, note: "" })),
];
const columns: ColumnDef<Week, any>[] = [
{ accessorKey: "week", header: "Week", size: 130, meta: { editable: true } },
{ accessorKey: "starts", header: "Starts", size: 140, meta: { editable: true } },
{ accessorKey: "visits", header: "Visits", size: 110, meta: { align: "end", editable: true, editor: "number" } },
{ accessorKey: "note", header: "Note", size: 160, meta: { editable: true } },
];
// Select the first two rows and drag the handle on the corner of the selection
// down: "Week 1, Week 2" continues as Week 3, Week 4..., the dates step by a week
// and the visits by 30. Ctrl+D copies the top row of a selection down instead.
export default function DataGridFillExample() {
const [rows, setRows] = useState(weeks);
return (
<DataGrid
label="Weekly visits"
data={rows}
columns={columns}
getRowId={(w) => w.id}
height={360}
// A fill changes many cells at once: apply them in one update.
onCellsEdit={(edits) =>
setRows((all) =>
all.map((row) => {
const mine = edits.filter((e) => e.rowId === row.id);
return mine.length ? { ...row, ...Object.fromEntries(mine.map((e) => [e.columnId, e.value])) } : row;
}),
)
}
onCellEdit={(e) => setRows((all) => all.map((r) => (r.id === e.rowId ? { ...r, [e.columnId]: e.value } : r)))}
/>
);
}API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
labelrequiredAccessible name of the grid, e.g. 'Orders'. | string | none |
dataRows to show. Keep the array reference stable between renders. | readonly unknown[] | none |
columnsTanStack Table column definitions. Give each column an id or accessorKey. | ColumnDef<any, any>[] | none |
getRowIdStable id per row. Needed for selection to survive sorting and filtering. | (row: any, index: number) => string | none |
densityRow height: 32, 40 or 48px. | "compact" | "standard" | "comfortable" | "standard" |
heightHeight of the scrolling area in px. | number | 480 |
virtualizedRenders only visible rows. Keeps 100k rows fast. | boolean | true |
sortableLets users sort by clicking or pressing Enter on a header. Shift adds a secondary sort. | boolean | true |
selectionModeAdds a checkbox column and Space-to-select. | "none" | "single" | "multiple" | "none" |
selectedRowIdsControlled selection, by row id. | readonly string[] | none |
defaultSelectedRowIdsInitial selection when uncontrolled. | readonly string[] | none |
onSelectionChangeCalled with the selected row ids. | (ids: string[]) => void | none |
globalFilterShows only rows where some cell contains this text (case-insensitive). | string | none |
resizableColumnsDrag a header edge, or press Alt+Left/Right on a header, to resize. | boolean | true |
pinnedColumnsColumn ids kept visible on the left while scrolling sideways. | readonly string[] | none |
showColumnFiltersAdds a filter row under the header. Set meta.filter on a column to 'text', 'select' or false. | boolean | false |
pageSizeRows per page. When set, a pager is shown; when not, all rows scroll (virtualized). | number | none |
onCellEditCalled when the user saves an edit in a column with meta.editable. Update your data to apply it. | (edit: DataGridCellEdit<any>) => void | none |
onCellsEditCalled once with every cell a paste changes. Prefer it to onCellEdit when you paste: applying a whole paste in one update avoids state updates overwriting each other. When it isn't set, a paste calls onCellEdit once per cell. | (edits: DataGridCellEdit<any>[]) => void | none |
rangeSelectionSelect a block of cells with Shift+arrows, Shift+click or by dragging. Ctrl+C copies it as spreadsheet-ready text; Ctrl+V pastes into editable cells. | boolean | true |
apiRefGives you getCsv(), downloadCsv() and downloadExcel() for the rows that pass the filters. Create it with useRef<DataGridApi>(null). | { current: DataGridApi | null } | none |
serverSideYour server does the sorting, filtering and paging. The grid shows the rows you pass (one page), reports what the user asked for through onQueryChange, and uses rowCount for the pager. Needs pageSize. Not combined with groupBy or getSubRows. | boolean | false |
rowCountserverSide: total rows on the server across all pages, after the current search and filters. | number | none |
onQueryChangeserverSide: called on mount and whenever the sort, filters, search or page change. Typing in a filter waits queryDelay ms; sorting and paging fire at once. Fetch, then pass the new data, rowCount and isLoading. | (query: DataGridQuery) => void | none |
queryDelayserverSide: milliseconds to wait after the last keystroke in a filter or search before calling onQueryChange. | number | 250 |
fillHandleA small handle on the corner of the selection: drag it to fill the cells it covers, continuing numbers (2, 4 gives 6, 8), text that ends in a number (Item 1 gives Item 2), ISO dates and otherwise repeating the values. Ctrl+D and Ctrl+R fill down and right from the keyboard. Needs rangeSelection and an edit handler (onCellEdit or onCellsEdit); only editable cells are written. | boolean | true |
columnMenuAdds a menu to every column header: sort, pin, reset width, hide, and show hidden columns again. Alt+Down, Shift+F10 or the Menu key on a header opens it. | boolean | false |
rowReorderAdds a drag handle column. Rows can be dragged, or moved with Alt+Up and Alt+Down. The grid reports the move through onRowReorder; you reorder your data. Off while sorting, filtering, grouping or serverSide is in use, because the rows are no longer in your data's order. | boolean | false |
onRowReorderCalled with { rowId, fromIndex, toIndex } when a row is moved. Apply it with reorderRows(rows, move). | (move: DataGridRowMove) => void | none |
isLoadingMarks the grid busy and shows a loading message. | boolean | false |
emptyMessageShown when there are no rows to display. | string | "No rows" |
onRowActionCalled on Enter or double-click on a row, e.g. to open details. | (row: any) => void | none |
onFilteredDataChangeCalled with the rows that match the search and column filters, in display order, whenever they change. Use it for counts and totals. | (rows: any[]) => void | none |
groupByColumn ids to group rows by, outermost first. Group rows show the value, a row count, and a total in each column that sets aggregationFn ('sum', 'mean', 'min', 'max', 'count', 'uniqueCount' or a function). | readonly string[] | none |
defaultExpandedStart with every group, tree row and detail panel open. | boolean | false |
getSubRowsTree data: returns a row's children. Rows with children get an expand toggle and are indented by depth. Search keeps a parent when a child matches. | (row: any) => readonly any[] | undefined | none |
renderDetailMaster-detail: content shown in a panel under a row when it's expanded. | (row: any) => ReactNode | none |
detailHeightHeight of each detail panel in px; the panel scrolls if its content is taller. | number | 200 |
Accessibility
Role grid, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab moves into the grid, to the last focused cell; Tab again leaves it
- Arrow keys move between cells, including the header row
- Home/End move to the first/last cell in the row
- Ctrl+Home/Ctrl+End move to the first/last cell in the grid
- PageUp/PageDown move by one screen of rows
- Enter or Space on a header sorts; with Shift it adds a secondary sort
- Alt+Left/Right on a header resizes the column
- Space on a row toggles its selection; Ctrl+A selects all (multiple mode)
- Enter on a row calls onRowAction
- Enter or F2 on an editable cell starts editing; typing a character starts editing with it, including the first key of an IME (Japanese, Chinese, Korean input)
- While editing, Enter saves and Escape cancels; focus returns to the cell
- Enter or typing on a filter cell moves into its filter; Escape returns to the grid
- On a group, tree or detail row, Right arrow in the toggle cell expands it and Left arrow collapses it; Enter on a group row (or on the toggle cell, when it isn't editable) toggles it
- Space on a group row selects or clears all its rows (multiple mode)
- Shift+arrows, Shift+Home/End and Shift+PageUp/PageDown extend a block of cells from the cell you started on; Escape clears it
- Ctrl+C (Cmd+C) copies the block as tab-separated text, the way it is shown; with no block it copies the focused cell
- Ctrl+V (Cmd+V) pastes spreadsheet text into editable cells from the focused cell; one copied value fills a selected block
- Ctrl+D (Cmd+D) copies the top row of the selection down across it, or with no selection copies the cell above; Ctrl+R (Cmd+R) does the same to the right. Only editable cells are written
- Alt+Down, Shift+F10 or the Menu key on a header opens its column menu; Escape closes it and returns to the header
- Enter or typing on a set-filter cell opens its checklist: arrows move, Space ticks a value, Escape closes it
- Alt+Up or Alt+Down on a row moves it up or down one place, when rowReorder is on and the grid is not sorted or filtered
Screen readers announce
- Entering the grid announces its name and size (rows and columns)
- Moving across announces each column header; moving down announces row numbers from the full dataset (row 50,000 of 100,001), even though rows are virtualized
- Sorting with Enter announces the new sort direction on that header
- Selected rows are announced as selected
- Filter cells: Enter or typing moves into an input announced as Filter <column>
- Editing: Enter opens an edit field announced as Edit <column>; after Enter, the saved value is read
- The pager's range (Rows 11–20 of 237) is announced after changing page
- Group and tree rows announce their level and whether they are expanded or collapsed
- Extending a block with Shift+arrows announces the number of cells selected (for example, 6 cells selected)
- Filling announces how many cells were filled and how many were skipped (3 cells filled, 1 skipped)
- Moving a row announces its new place (Moved Ada to position 3 of 20); a drag announces the same on drop
- Copying announces how many cells were copied; pasting announces how many were changed and how many were skipped
- With NVDA, table navigation (Ctrl+Alt+Arrow keys) works in browse mode
What your code must do
- On touch screens, tapping the selected editable cell again opens its editor, so the phone keyboard appears
- Grid has an accessible name
- aria-rowcount and aria-rowindex reflect the full dataset, even when virtualized
- Sorted columns expose aria-sort
- Selected rows expose aria-selected
- Only one cell is in the tab order at a time (roving tabindex)
- Filter inputs and editors are labelled with their column name
- Pager announces the visible row range
- With serverSide, aria-rowcount and the pager use rowCount, so assistive technology reports the full dataset while only one page is loaded; the busy state is exposed as aria-busy
- With grouping, tree data or detail rows the role is treegrid; rows expose aria-level and, when they can open, aria-expanded
- Group rows show their row count; only columns with an aggregationFn show a total, never the first row's value
- The expand toggle is a labelled button (Expand / Collapse) that isn't a separate tab stop
- The fill handle is a mouse shortcut (hidden from assistive technology): everything it does is also available as Ctrl+D and Ctrl+R, and every fill is announced with how many cells were filled and skipped
- The column menu button has an accessible name (<column> column menu) and isn't a tab stop; the menu is a real menu with focus returned to the header on close
- The set filter's button names the filter and summarises it (All, one value, or N selected); its checklist is a multi-select listbox
- Each row's drag handle is labelled with the row and says Alt+Up or Alt+Down moves it, and says so when reordering is off; every move is announced
- Cells in a selected block expose aria-selected, and a polite status announces how many cells are selected, copied or pasted
Guidelines
Use it when
- Scanning, sorting and acting on many records, like orders, users or logs
Avoid it when
- A few items with rich layout: use a list or cards
- Pure layout: never use a grid for layout
Don't
- Buttons in every cell instead of a row action
- Horizontal scrolling without pinning the identifying column
- Formatting numbers or dates in a custom cell renderer: search and filters won't find the text users see. Use meta.format (e.g. a currency formatter) instead
- Rendering the grid straight from a React Server Component: getRowId, onCellEdit and the other callbacks are functions, which cannot cross the server boundary. Put the grid in your own component marked "use client"
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-surface-default--rd-color-surface-subtle--rd-color-surface-selected--rd-color-border-default--rd-color-border-strong--rd-color-text-default--rd-color-text-muted--rd-color-action-primary--rd-color-focus-ring--rd-radius-overlay