Columns
Resize, reorder, pin, and show/hide columns.
Resize and drag-to-reorder are built into the core, with no add-on needed. Pin, unpin, and
visibility get their UI from data-grid-context-menu (a header dropdown and a right-click menu)
and from data-grid-toolbar (a columns menu).
See examples: Pinning · Drag & drop · Filtering · Custom headers · Column layout · Column groups
Drag a header's edge to resize (Email is fixed), double-click an edge to autosize the long Bio column, or drag a header to reorder. Hover a header for its menu (or right-click) to pin, hide, or autosize — the toolbar's columns menu restores anything hidden.
Resize
Drag the inline-end edge of a header to resize it. This uses pointer capture, with no ghost
element. Double-click the handle to autosize the column to its content. Per-column
resizable: false, or grid-wide enableColumnResize={false} on DataGridProvider, disables
resize.
A column without width renders at the 150px default. A resize clamps to the column's
minWidth, with a 32px absolute floor that no minWidth can lower.
Autosize
Double-click the handle to size the column to its content. The header menu's "Autosize column" and "Autosize all columns" items do the same for one or every visible column (both from the context menu add-on).
Autosize measures sampleRows view rows (default 1000, clamped to [1, 10000]) evenly strided
across the sorted and filtered view, plus every rendered row. It sizes the column to the widest
measured display text and the header. A column whose widest value sits off-screen still fits it through the sample.
Sampled texts are cut to 1000 characters.
actions.autosizeColumns(options) runs the pass programmatically; it commits every changed
width in one write, fires onColumnLayoutChange once, and returns the measured widths:
columnIds: the columns to size; default is every visible column while column resizing is enabled. Hidden, non-resizable, and unknown ids are skipped.sampleRows: view rows to sample; default 1000.includeHeader: measure the header text too; default true.
Pass autosizeOnMount on DataGridProvider (or true for the defaults) to run one pass
automatically when the view first has rows; a lazy grid waits for its first page. It runs once
per mount, and columns with a saved width keep it. A saved layout wins.
Reorder
Drag a header to reorder columns. Per-column reorderable: false, or grid-wide
enableColumnReorder={false}, disables reorder. Pinned columns only reorder within their own pin
zone. Dragging a left-pinned column onto the unpinned band does nothing. It never changes the pin.
Pin start/end
Set pin: 'left' | 'right' per column as an initial value, or change it at runtime through the
header dropdown menu or the right-click header context menu, both installed by the
context menu add-on, which owns the menu wiring and its items (sort,
pin, autosize, hide). The menu items read "Pin to start" New and "Pin to end". pin: "left" is the
start side, pin: "right" the end side. In a left-to-right page, start is left and end is right. In a right-to-left page, start is right and end is left.
Right-click selects the cell first
Right-clicking a cell makes it the active cell with a single-cell selection before the menu opens. The menu's copy, paste, and row operations act on the right-clicked cell. They work even when the user did not select the cell first.
The renderHeaderMenu slot of DataGridRoot renders at the inline-end of each header cell. The
DataGrid wrapper forwards it. It shows as a ghost chevron button on hover and opens the same
menu as a right-click. Per-column
pinnable: false, or grid-wide enableColumnPinning={false}, disables pin and unpin.
The renderHeaderFilter slot of DataGridRoot renders a filter button in each header cell
whose column offers a set filter. It receives { column, index }. The DataGridHeaderFilter
component of the toolbar add-on fills it. A header cell
carries data-filtered while its column has a filter.
A soft directional shadow marks the pinned boundary: the last start-pinned column casts a shadow toward the end side, and the first end-pinned column casts one toward the start side, whenever content is actually scrolled beneath it.
Persistent pin indicator
The shadow marks the pinned boundary, not which individual headers are pinned. For a small icon that stays on every pinned header regardless of scroll position, two paths already exist, with no API change needed.
CSS-only, keyed off the same data-pinned="left" | "right" attribute that the boundary
shadow uses:
[role="columnheader"][data-pinned="left"]::after,
[role="columnheader"][data-pinned="right"]::after {
content: "📌"; /* or an inline SVG/mask-image using a real icon set */
margin-inline-start: 0.25rem;
font-size: 0.75em;
opacity: 0.6;
}React icon, through the header: ReactNode column field. Render a lucide Pin next to the
label, so it composes with your own header content, such as labels and tooltips, instead of a
CSS pseudo-element:
import { Pin } from "lucide-react";
const columns = defineColumns<Employee>()([
{
id: "name",
header: (
<span className="flex items-center gap-1">
Name
<Pin className="size-3 text-muted-foreground" aria-hidden="true" />
</span>
),
accessorKey: "name",
type: "text",
pin: "left",
},
] as const);Conditional on pin state
The pin of a column can change at runtime, through the header dropdown or the right-click
menu. To show the icon only once a column is actually pinned, read the current state back
with useDataGridAllColumns() or useDataGridVisibleColumns(), rather than showing it
unconditionally on a column you know is pinned by default. See
Reading column state below.
Custom headers
ColumnDef.header accepts a ReactNode, and the ReactNode can be a component. That is the real
use: a header that renders live grid state, such as a sort-direction icon that follows the
current sort, and it composes with resizing, reordering, and the header menu. String headers
render truncated. A non-string header renders as-is.
With headerClickBehavior="sort" on the provider, a header click cycles the column's sort
(asc -> desc -> none). In that mode a string header gets the built-in DataGridSortIndicator
appended after the label, while a custom header owns its display. The demo's headers
subscribe to the sort state and render a three-state icon: a muted minus when unsorted, an
up icon when ascending, a down icon when descending:
function SortStateIcon(props: { columnId: string; unsorted: ReactNode; asc: ReactNode; desc: ReactNode }) {
const sortState = useDataGridSortState();
const entry = sortState.find((spec) => spec.columnId === props.columnId);
const stateIcon = entry?.direction === "asc" ? props.asc : entry?.direction === "desc" ? props.desc : props.unsorted;
return <span className="flex shrink-0 items-center">{stateIcon}</span>;
}
const columns = defineColumns<Employee>()([
{
id: "score",
header: (
<span className="flex items-center gap-1.5">
Score
<SortStateIcon
columnId="score"
unsorted={<Minus className="size-3.5 text-muted-foreground" aria-hidden="true" />}
asc={<TrendingUp className="size-3.5 text-muted-foreground" aria-hidden="true" />}
desc={<TrendingDown className="size-3.5 text-muted-foreground" aria-hidden="true" />}
/>
</span>
),
headerText: "Score",
accessorKey: "score",
type: "number",
},
] as const);A custom header that wants the built-in arrow can embed DataGridSortIndicator itself (it
renders nothing while the column is unsorted). For an icon-only or component header,
headerText is required: without it, the screen-reader label and the header menu fall back
to the raw column id. headerClassName styles the whole header cell.
Header cell contents
ColumnDef.header replaces the label inside the built-in cell. The renderHeaderCell slot on
DataGridHeader replaces the contents of the cell. The grid keeps the cell element itself: its
placement, pinned offset, role, aria-colindex, sort and select gestures, and resize handle.
The slot is called once per visible column with ctx and Default. ctx is
{ column, index, width, headerClickBehavior, sortState }. Default is the built-in contents:
the label, the sort indicator, and the header menu trigger. Return Default to keep them; a
custom node replaces them:
<DataGridRoot className="h-[400px]">
<DataGridHeader
renderHeaderCell={(ctx, Default) => (ctx.column.id === "name" ? <MyNameHeader column={ctx.column} /> : Default)}
/>
<DataGridBody />
</DataGridRoot>Interactive content in the slot keeps its own clicks. When a press starts on a button, a link, a
form control, a role="button" element, or a contenteditable element, the header cell does not
sort, select, or start a column reorder.
The slot mounts inside DataGridRoot, so it needs the composed parts. DataGrid (the
convenience wrapper) does not forward it.
Column groups
A column group puts one shared header above a run of columns. The columnGroups prop of
DataGridProvider defines the groups by column id. The columns array stays flat.
import type { ColumnGroupDef } from "@/components/data-grid/data-grid";
const columnGroups: ColumnGroupDef[] = [
{ id: "contact", header: "Contact", children: ["name", "email"] },
{
id: "work",
header: "Work",
children: ["role", { id: "history", header: "History", children: ["joined", "age"] }],
},
];
<DataGridProvider columns={columns} columnGroups={columnGroups} /* other props */ />;| Field | Meaning |
|---|---|
id | The group id. It is unique across all groups and all columns. |
header | The label: a string or a node. |
headerText | Plain text for assistive technology and for the columns menu when header is a node. |
children | Column ids and nested groups, in the order you list them. |
headerClassName | Classes for the group header cell. |
The header has one row for each group level, and one row for the column headers. The
headerHeight prop sets the height of one row. A column that sits outside every group spans all
header rows. A column in a shallower group spans the rows that remain.
A group keeps its columns next to each other:
- Dragging a column moves it inside its group, or next to a whole sibling group. A drop that would take the column out of its group does nothing.
- Dragging a group header moves the group as one block inside its parent group. Dragging a group that pinning has split across pin zones does nothing.
- A saved or URL layout whose
ordersplits a group is repaired. All members move to the position of the first member.
Pinning stays per column. A group with columns in two pin zones shows one header cell for each zone, and each cell carries the same label. Hiding a column shrinks the group header. Hiding every column of a group removes the group header.
A press on a group header selects the group's visible columns. Shift extends the selection.
Ctrl (Cmd on macOS) adds to it. When headerClickBehavior is "sort", the press does nothing.
Group headers do not sort, resize, or filter. The columns menu of the toolbar add-on lists the
members of a group under a heading with the group label.
The grid checks columnGroups and ignores what it cannot use:
- An id that is not a column is dropped.
- A column that two groups list stays in the first group.
- A group whose id repeats, or equals a column id, is dropped.
- Nesting deeper than 8 levels is dropped, and so is everything after the first 5000 entries.
In development, the grid logs each dropped entry once. The grid compares an inline
columnGroups array by structure, so a new array with the same groups does not rebuild the
header. A header written inline as JSX is a new node on every render and rebuilds the header, so
memoize columnGroups or define the header nodes outside the component.
Export and print write the column headers only. Group headers are not part of the output.
headerHeight in the root context is the height of the whole header block. Code that
positions content under the header reads this value, and it grows when you set columnGroups.
The aria-rowindex of data rows grows by the same number of rows.
Visibility
The DataGridColumnsMenu of data-grid-toolbar is show and hide only. Pin controls live in the
header menu, not here. It is a dropdown of checkmark rows, one per column:
import { DataGridToolbar, DataGridColumnsMenu } from "@/components/data-grid-toolbar/data-grid-toolbar";
<DataGridToolbar>
<DataGridColumnsMenu />
</DataGridToolbar>;Or set a column's initial state with hidden: true in its ColumnDef. It is an initial value,
not a fixed state: setColumnHidden and the columns menu override it, and the flag re-applies
only when you pass a new columns array.
Flex fill
Per-column flex: number grows a column from its base width to fill leftover viewport space,
proportionally to other flex columns, the same ratio model as CSS flexbox flex-grow. A column
with no flex, or with flex: 0, never grows past its own width.
const columns = defineColumns<Row>()([
{ id: "name", header: "Name", accessorKey: "name", width: 160, flex: 1 },
{ id: "email", header: "Email", accessorKey: "email", width: 200, flex: 2 },
{ id: "age", header: "Age", accessorKey: "age", width: 80 },
] as const);Here email grows twice as fast as name as the container widens, and age stays fixed. Growth
is clamped by maxWidth, and a flex column never shrinks below its base width. A manual resize
fixes that column at its new width and drops it out of flex distribution, the same as a column
with no flex.
Reading column state
State hooks let you build your own UI around any of the features above:
useDataGridColumnWidth(id), useDataGridColumnWidths(ids), useDataGridIsColumnHidden(id),
useDataGridVisibleColumns(), and useDataGridAllColumns(). Actions live on
useDataGridActions(): setColumnWidth, resetColumnWidth, setColumnOrder,
setColumnPin, and setColumnHidden.
Persisting a user's layout
setColumnWidth, resetColumnWidth, setColumnOrder, setColumnPin, and
setColumnHidden are the imperative actions, but none of them notify you on their own. To save a user's column layout and restore it
on the next visit, use the dedicated pair below instead of subscribing to the actions:
defaultColumnLayout?: Partial<ColumnLayout>; // seeds widths/order/pins/hidden ONCE at mount
columnLayout?: Partial<ColumnLayout>; // controlled, per part; an omitted part stays uncontrolled
onColumnLayoutChange?: (next: ColumnLayout) => void; // fires once per committed changedefaultColumnLayout seeds the layout once, when the grid mounts. Later changes to the prop are
ignored. Use it when the saved layout is known at the first render, for example when it comes
from props or a cookie. localStorage does not exist on the server, so a layout read from it is
not known at the first render in Next.js. Use the controlled columnLayout prop instead, and
load the saved value in an effect. (To keep the order and the hidden columns in a shareable link
instead, use the URL state add-on.) Example:
"use client";
import { useEffect, useState } from "react";
import type { ColumnLayout } from "@/components/data-grid/data-grid";
const LAYOUT_KEY = "my-grid-layout";
const [layout, setLayout] = useState<Partial<ColumnLayout>>();
useEffect(() => {
const saved = localStorage.getItem(LAYOUT_KEY);
if (saved) setLayout(JSON.parse(saved) as ColumnLayout);
}, []);
<DataGridProvider
{...grid}
columns={columns}
columnLayout={layout}
onColumnLayoutChange={(next) => {
setLayout(next);
localStorage.setItem(LAYOUT_KEY, JSON.stringify(next));
}}
>The grid shows the default widths for one frame before the saved layout arrives.
localStorage does not exist on the server, so the effect reads it after mount. The grid renders
the default layout first, then applies the saved one.
onColumnLayoutChange fires once per committed change: a resize commit (a drag release, or an
autosize from a double-click or the header menu, never per drag frame), a width reset, a
reorder drop, a pin or unpin, or a show or hide. See
Events & state
for the full payload shape, the controlled columnLayout prop, and the live, per-drag-frame
onColumnResizing. defaultColumnLayout
is not a controlled prop: the grid reads it once at mount and ignores later changes, so always
pair it with onColumnLayoutChange. An app that owns the layout state instead passes
columnLayout: each given part replaces that part, and an omitted part keeps the store's
current value, so columnLayout={{ widths }} controls widths only and a user reorder survives.
A column that the layout order does not list, such as a
column added after the layout was saved, follows the listed columns in columns order. The
grid drops layout entries for unknown column ids, clamps each width to the column's
minWidth/maxWidth, and ignores a pin on a pinnable: false column. A hidden: true column
that the layout order does not list stays hidden. The grid ignores a layout part with the wrong
shape, such as an order that is not an array, so a damaged saved layout never breaks the grid.
useDataGridVisibleColumns() and useDataGridAllColumns() return the full ColumnDef of each
column. So a custom column UI can call accessorFn, setValue, or a function-form readOnly
directly against the row it renders, with no cast needed:
function CustomColumnLabel({ row }: { row: unknown }) {
const columns = useDataGridVisibleColumns();
return columns.map((column) => {
const value = column.accessorFn?.(row);
return <span key={column.id}>{column.headerText ?? column.id}: {String(value)}</span>;
});
}Pass your own row type as the hook's generic (useDataGridVisibleColumns<Employee>()) to get
accessorFn and setValue typed against that type.
Pinning rows, for a sticky totals band at the top or bottom, uses the same sticky, frozen-edge machinery. See Pinned rows.