Composition and custom layouts
<DataGrid> is a toolbar, a table and a pager in a sensible order. When the order, or the toolbar,
isn’t yours, build the same grid from the same parts.
Customers
| Zara Kim | Active | Sales | $36.00 | |
| Vihaan Silva | Active | Data | $174.00 | |
| Olivia Dubois | Active | Growth | $36.00 | |
| Aarav Okafor | Active | Design | $12.00 | |
| Arjun Tanaka | Invited | Sales | $0.00 | |
| Diya Nair | Invited | Design | $0.00 | |
| Saanvi Dubois | Active | Data | $1,508.00 | |
| Idris Iyer | Paused | Platform | $0.00 | |
| Rohan Nair | Churned | Growth | $0.00 | |
| Freya Kim | Active | Data | $0.00 | |
| Σ 39,623 |
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";import { DataGridBulkActions, DataGridDensityMenu, DataGridExportMenu, DataGridFilterChips, DataGridPagination, DataGridRoot, DataGridSearch, DataGridTable, DataGridViewOptions, createDataGridColumns, useDataGrid,} from "@/components/ui/data-grid";
import { makePeople, 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" }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.text("team", { header: "Team" }), col.currency("mrr", { header: "MRR", currency: "USD", footer: ({ column }) => `Σ ${column.getAggregationValue<number>().toLocaleString()}`, }),]);const data = makePeople(60, 5);
/** Your own layout from the parts: a title row, the search beside your buttons, the pager on top. */export default function DataGridComposed() { const grid = useDataGrid<Person>({ key: "composed", data, columns, getRowId: (r) => r.id }); return ( <DataGridRoot grid={grid}> <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex items-center gap-2"> <h2 className="text-base font-semibold">Customers</h2> <DataGridSearch className="max-w-56" /> <DataGridFilterChips /> </div> <div className="flex items-center gap-2"> <DataGridViewOptions /> <DataGridDensityMenu /> <DataGridExportMenu filename="customers" /> <Button size="sm"> <PlusIcon /> New customer </Button> </div> </div> <DataGridBulkActions> {(rows) => ( <Button variant="outline" size="sm" onClick={() => alert(`${rows.length} rows`)}> Export selected </Button> )} </DataGridBulkActions> <DataGridPagination /> <DataGridTable height={420} striped /> </DataGridRoot> );}Shape the all-in-one first
Section titled “Shape the all-in-one first”Most layouts don’t need composition. <DataGrid> takes the options of useDataGrid plus these
presentation props:
| Prop | What it does |
|---|---|
toolbar |
false hides the toolbar, a node replaces it, a DataGridToolbarProps object configures it ({ density: false, views: false }). |
toolbarActions |
Your controls inside the built-in toolbar, before its menus. |
pager |
false hides the pager (rows still page unless pagination: false); a DataGridPaginationProps object configures it. |
bulkActions |
(rows, api) => ReactNode, rendered in the bar that appears while rows are selected. |
height, fillHeight, stickyHeader, striped, getRowClassName, onRowClick, tableClassName |
Passed to the table (below). |
loading, error, onRetry, emptyState |
The states. |
fullscreen, onFullscreenChange |
Control the fullscreen toggle. |
grid |
A DataGridApi from useDataGrid, when you need the api outside the component. |
const grid = useDataGrid({ data, columns, getRowId: (r) => r.id })
<DataGrid grid={grid} toolbar={{ views: false }} toolbarActions={<Button>New</Button>} striped height={520} /><p>{grid.table.getFilteredRowModel().rows.length} match</p>Compose it
Section titled “Compose it”useDataGrid(options) builds the table and everything the parts need. <DataGridRoot grid={grid}>
provides it to whatever you render inside, and gives the grid its frame: the --dg-row-height
variable, data-density, the fullscreen box, the tooltip provider and a live region for screen
reader announcements. Inside, place the parts in any order:
import { DataGridBulkActions, DataGridDensityMenu, DataGridExportMenu, DataGridFilterChips, DataGridPagination, DataGridRoot, DataGridSearch, DataGridTable, DataGridViewOptions, useDataGrid,} from "@/components/ui/data-grid"
const grid = useDataGrid<Person>({ key: "customers", data, columns, getRowId: (r) => r.id })
<DataGridRoot grid={grid} loading={isFetching}> <div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-2"> <h2 className="text-base font-semibold">Customers</h2> <DataGridSearch className="max-w-56" /> <DataGridFilterChips /> </div> <div className="flex items-center gap-2"> <DataGridViewOptions /> <DataGridDensityMenu /> <DataGridExportMenu filename="customers" /> <Button size="sm">New customer</Button> </div> </div> <DataGridBulkActions>{(rows) => <Button size="sm">Export {rows.length}</Button>}</DataGridBulkActions> <DataGridPagination /> <DataGridTable height={420} striped /></DataGridRoot>Every part reads the grid from context, so there is nothing to pass between them. A part that has
nothing to show renders nothing: the grouping bar with no grouping, the bulk bar with no selection,
the views menu without persist, the pager with pagination: false.
The parts
Section titled “The parts”| Part | Props | Renders |
|---|---|---|
DataGridRoot |
grid, className, loading, error, onRetry, emptyState, fullscreen, onFullscreenChange |
The provider and frame. Everything else goes inside it. |
DataGridToolbar |
className, start, children, search, filters, columns, grouping, density, export, views, fullscreen (all true), exportFilename |
Search, filter chips and the grouping bar on the left; children then the menus on the right. start goes after the search. |
DataGridSearch |
className |
The debounced search box. Nothing when globalFilter: false. |
DataGridFilterChips |
className |
One chip per active column filter, each removable, and a Clear. |
DataGridGroupingBar |
className |
The grouped columns as chips. |
DataGridViewOptions |
className |
The View popover: column visibility, with search, Show all and Reset view. |
DataGridGroupMenu |
className |
The Group menu. Nothing when grouping: false or no column is groupable. |
DataGridDensityMenu |
className |
Compact / Comfortable / Spacious. |
DataGridExportMenu |
className, filename |
CSV, JSON, copy to clipboard, selected rows only. |
DataGridViewsMenu |
className |
Saved views. Nothing unless the grid persists with savedViews. |
DataGridFullscreenToggle |
className |
Fullscreen on and off. |
DataGridTable |
className, height, fillHeight, stickyHeader (true), striped, getRowClassName, onRowClick |
The table: header, body (virtualized when large), footer and the state overlays. |
DataGridPagination |
className, showSelection, showGoTo |
Rows per page, page x of y, first / previous / next / last. showGoTo defaults to on past 5 pages, showSelection to whether selection is on. |
DataGridBulkActions |
className, children: (rows, api) => ReactNode |
The bar while rows are selected: the count, Clear selection, your actions. |
Table props worth knowing:
heightis a number of pixels or any CSS length.fillHeightmeasures from the grid’s top edge to the bottom of its nearest scrolling ancestor (minus that ancestor’s bottom padding), never less than 240 px, and re-measures on resize, so a console page gets one scrollbar, inside the grid.stripedtints every other row.getRowClassName(row)adds classes to a row; the row is a TanStackRow, sorow.originalis your data.onRowClick(row, event)runs before the grid’s own row click. When the grid has no checkbox column and cell selection is off, a click toggles the row’s selection; callevent.preventDefault()in your handler to stop that.
Lower-level exports for custom columns: DataGridDefaultCell, DataGridGroupCell,
DataGridAggregatedCell, DataGridSelectCell, DataGridSelectHeader, DataGridExpanderCell,
DataGridExpanderHeader, DataGridColumnHeader, DataGridColumnFilter, DataGridCellEditor.
A part of your own
Section titled “A part of your own”Any component rendered inside DataGridRoot can call useDataGridContext<TData>() and get the
DataGridApi: table (the TanStack instance, with table.state), labels, locale, density and
setDensity, rowHeight, pagination, isServer, views, editing, exportCsv, exportJson,
copyToClipboard, exportTable, metaFor(columnId) and labelFor(columnId). It throws outside a
grid; useOptionalDataGridContext() returns null instead, for a part that can stand alone.
Here is a toolbar part that resets every slice of state, placed in the built-in toolbar through
children:
| Theo Nair | Invited | Free | Research | $0.00 | |
| Liam Nair | Active | Team | Data | $551.00 | |
| Yuki Dubois | Active | Pro | Sales | $48.00 | |
| Rohan Kim | Active | Free | Research | $0.00 | |
| Olivia Iyer | Active | Enterprise | Sales | $4,740.00 | |
| Mia Okafor | Active | Pro | Finance | $12.00 | |
| Leila Silva | Active | Free | Sales | $0.00 | |
| Omar Nair | Active | Free | Design | $0.00 | |
| Sofia Silva | Active | Team | Growth | $1,276.00 | |
| Omar Rossi | Invited | Enterprise | Research | $2,212.00 |
import { RotateCcwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";import { DataGridPagination, DataGridRoot, DataGridTable, DataGridToolbar, createDataGridColumns, useDataGrid, useDataGridContext,} from "@/components/ui/data-grid";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
/** A toolbar part of your own. It reads the grid from context, so it works inside any grid. */function ResetEverything() { const { table } = useDataGridContext<Person>(); const { state } = table; const dirty = state.sorting.length > 0 || state.columnFilters.length > 0 || Boolean(state.globalFilter) || state.grouping.length > 0 || Object.keys(state.rowSelection).length > 0 || Object.keys(state.columnVisibility).length > 0 || state.columnOrder.length > 0 || state.columnPinning.start.length + state.columnPinning.end.length > 0 || Object.keys(state.columnSizing).length > 0; return ( <Button variant="outline" size="sm" className="h-8" disabled={!dirty} onClick={() => { table.resetSorting(true); table.resetColumnFilters(true); table.resetGlobalFilter(true); table.resetGrouping(true); table.resetRowSelection(true); table.resetColumnVisibility(true); table.resetColumnOrder(true); table.resetColumnPinning(true); table.resetColumnSizing(true); table.setPageIndex(0); }} > <RotateCcwIcon /> Reset everything </Button> );}
const col = createDataGridColumns<Person>();
const columns = col.columns([ col.select(), col.text("name", { header: "Name" }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS }), col.text("team", { header: "Team" }), col.currency("mrr", { header: "MRR", currency: "USD" }),]);
const data = makePeople(60, 43);
/** The built-in toolbar with one extra part; sort, filter or pin something and the button wakes up. */export default function DataGridCustomPart() { const grid = useDataGrid<Person>({ key: "custom-part", data, columns, getRowId: (r) => r.id }); return ( <DataGridRoot grid={grid}> <DataGridToolbar views={false} fullscreen={false}> <ResetEverything /> </DataGridToolbar> <DataGridTable /> <DataGridPagination /> </DataGridRoot> );}It reads table.state to decide whether anything is dirty, and calls TanStack’s reset* methods
with true so each slice goes back to its default rather than to initialState. If the grid
persists, grid.views.reset() is the one call that restores the default view and clears the active
saved view.
useDataGridUi() is the second context: rootRef (the grid’s root element), fullscreen and
setFullscreen, loading, error, onRetry, emptyState, and announce(message), which speaks
through the grid’s live region. Say what your part did: announce("Reset").
See also
Section titled “See also”- Loading and empty states: the props on
DataGridRoot. - Saved views and persistence: what
views.reset()restores. - API reference: every option of
useDataGridand every field of the api.