Skip to content

Data Grid

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.

Datav0.9.0experimentalWCAG 2.2 AAView spec
Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59
Person 11
Growth
Pune
32
Person 12
Support
Tokyo
45
Person 13
Design
Toronto
58
Person 14
Platform
London
31
Person 15
Growth
Berlin
44
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-grid

Copies 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.json

Works, 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

Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59
Person 11
Growth
Pune
32
Person 12
Support
Tokyo
45
Person 13
Design
Toronto
58
Person 14
Platform
London
31
Person 15
Growth
Berlin
44
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

Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59
Person 11
Growth
Pune
32
Person 12
Support
Tokyo
45
Person 13
Design
Toronto
58
Person 14
Platform
London
31
Person 15
Growth
Berlin
44
Person 16
Support
Pune
57
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

Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59
Person 11
Growth
Pune
32
Person 12
Support
Tokyo
45
Person 13
Design
Toronto
58
Person 14
Platform
London
31
Person 15
Growth
Berlin
44
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

Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59
Person 11
Growth
Pune
32
Person 12
Support
Tokyo
45
Person 13
Design
Toronto
58
Person 14
Platform
London
31
Person 15
Growth
Berlin
44
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

Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59
Person 11
Growth
Pune
32
Person 12
Support
Tokyo
45
Person 13
Design
Toronto
58
Person 14
Platform
London
31
Person 15
Growth
Berlin
44
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

Name
Team
Age
Person 1
Design
22
Person 2
Platform
35
Person 3
Growth
48
Person 4
Support
61
Person 5
Design
34
Person 6
Platform
47
Person 7
Growth
60
Person 8
Support
33
Person 9
Design
46
Person 10
Platform
59
Person 11
Growth
32
Person 12
Support
45
Person 13
Design
58
Person 14
Platform
31
Person 15
Growth
44
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

Name
Team
City
Age
Person 1
Design
Pune
22
Person 2
Platform
Tokyo
35
Person 3
Growth
Toronto
48
Person 4
Support
London
61
Person 5
Design
Berlin
34
Person 6
Platform
Pune
47
Person 7
Growth
Tokyo
60
Person 8
Support
Toronto
33
Person 9
Design
London
46
Person 10
Platform
Berlin
59

Rows 1–10 of 60

Page 1 of 6
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

Region
Rep
Product
Amount
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

Name
Kind
Size (KB)
src
Folder
58
components
Folder
41
button.tsx
File
4
data-grid.tsx
File
37
index.ts
File
2
styles.css
File
15
docs
Folder
12
readme.md
File
12
package.json
File
1
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

Customer
Plan
Orders
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

Item
Region
Units
Price
Notebook
North
5
$4.50
Pen set
West
16
$11.50
Desk lamp
East
27
$18.50
Monitor stand
South
38
$25.50
Keyboard
North
49
$32.50
Notebook
West
60
$39.50
Pen set
East
11
$46.50
Desk lamp
South
22
$53.50
Monitor stand
North
33
$60.50
Keyboard
West
44
$67.50
Notebook
East
55
$74.50
Pen set
South
6
$81.50
Desk lamp
North
17
$88.50
Monitor stand
West
28
$5.50
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

Order
Customer
Status
Total
Loading…

No rows

Page 1 of 1
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

Ticket
Title
Status
Owner
Priority
T-100
Login fails
Open
Ada
1
T-101
Slow search
Done
Katherine
2
T-102
Wrong total
In progress
Alan
3
T-103
Typo in footer
Won't fix
Margaret
4
T-104
Export is empty
Blocked
Linus
1
T-105
Crash on save
Open
Grace
2
T-106
Login fails
Done
Ada
3
T-107
Slow search
In progress
Katherine
4
T-108
Wrong total
Won't fix
Alan
1
T-109
Typo in footer
Blocked
Margaret
2
T-110
Export is empty
Open
Linus
3
T-111
Crash on save
Done
Grace
4
T-112
Login fails
In progress
Ada
1
T-113
Slow search
Won't fix
Katherine
2
T-114
Wrong total
Blocked
Alan
3
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

Reorder
Step
Owner
Collect requirements
Ada
Draw the first design
Grace
Review with the team
Linus
Build the prototype
Margaret
Test with users
Alan
Ship it
Katherine
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

Week
Starts
Visits
Note
Week 1
2026-01-05
120
Launch
Week 2
2026-01-12
150
0
0
0
0
0
0
0
0
0
0
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.

PropTypeDefault
labelrequired

Accessible name of the grid, e.g. 'Orders'.

stringnone
data

Rows to show. Keep the array reference stable between renders.

readonly unknown[]none
columns

TanStack Table column definitions. Give each column an id or accessorKey.

ColumnDef<any, any>[]none
getRowId

Stable id per row. Needed for selection to survive sorting and filtering.

(row: any, index: number) => stringnone
density

Row height: 32, 40 or 48px.

"compact" | "standard" | "comfortable""standard"
height

Height of the scrolling area in px.

number480
virtualized

Renders only visible rows. Keeps 100k rows fast.

booleantrue
sortable

Lets users sort by clicking or pressing Enter on a header. Shift adds a secondary sort.

booleantrue
selectionMode

Adds a checkbox column and Space-to-select.

"none" | "single" | "multiple""none"
selectedRowIds

Controlled selection, by row id.

readonly string[]none
defaultSelectedRowIds

Initial selection when uncontrolled.

readonly string[]none
onSelectionChange

Called with the selected row ids.

(ids: string[]) => voidnone
globalFilter

Shows only rows where some cell contains this text (case-insensitive).

stringnone
resizableColumns

Drag a header edge, or press Alt+Left/Right on a header, to resize.

booleantrue
pinnedColumns

Column ids kept visible on the left while scrolling sideways.

readonly string[]none
showColumnFilters

Adds a filter row under the header. Set meta.filter on a column to 'text', 'select' or false.

booleanfalse
pageSize

Rows per page. When set, a pager is shown; when not, all rows scroll (virtualized).

numbernone
onCellEdit

Called when the user saves an edit in a column with meta.editable. Update your data to apply it.

(edit: DataGridCellEdit<any>) => voidnone
onCellsEdit

Called 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>[]) => voidnone
rangeSelection

Select 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.

booleantrue
apiRef

Gives you getCsv(), downloadCsv() and downloadExcel() for the rows that pass the filters. Create it with useRef<DataGridApi>(null).

{ current: DataGridApi | null }none
serverSide

Your 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.

booleanfalse
rowCount

serverSide: total rows on the server across all pages, after the current search and filters.

numbernone
onQueryChange

serverSide: 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) => voidnone
queryDelay

serverSide: milliseconds to wait after the last keystroke in a filter or search before calling onQueryChange.

number250
fillHandle

A 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.

booleantrue
columnMenu

Adds 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.

booleanfalse
rowReorder

Adds 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.

booleanfalse
onRowReorder

Called with { rowId, fromIndex, toIndex } when a row is moved. Apply it with reorderRows(rows, move).

(move: DataGridRowMove) => voidnone
isLoading

Marks the grid busy and shows a loading message.

booleanfalse
emptyMessage

Shown when there are no rows to display.

string"No rows"
onRowAction

Called on Enter or double-click on a row, e.g. to open details.

(row: any) => voidnone
onFilteredDataChange

Called with the rows that match the search and column filters, in display order, whenever they change. Use it for counts and totals.

(rows: any[]) => voidnone
groupBy

Column 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
defaultExpanded

Start with every group, tree row and detail panel open.

booleanfalse
getSubRows

Tree 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[] | undefinednone
renderDetail

Master-detail: content shown in a panel under a row when it's expanded.

(row: any) => ReactNodenone
detailHeight

Height of each detail panel in px; the panel scrolls if its content is taller.

number200

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