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).
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.
Autosize measures the visible window only
Autosize (double-click and the header menu's "Autosize column") measures only the currently rendered, windowed cells. On a virtualized grid that is the visible window, not the whole column: a column whose widest value sits off-screen fits the visible rows, and a long dataset can make it shrink. It is a viewport fit, not a global-content width.
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 left/right
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 menus, installed by
data-grid-context-menu, show the same items: Sort asc/desc/clear, Pin left/right/unpin,
Autosize, Hide.
pnpm dlx shadcn@latest add @gridcn/data-grid-context-menu
import {
DataGridContextMenu,
DataGridHeaderDropdown,
} from "@/components/data-grid-context-menu/data-grid-context-menu";
<DataGridProvider {...grid} columns={columns}>
<DataGridContextMenu>
<DataGridRoot renderHeaderMenu={(ctx) => <DataGridHeaderDropdown {...ctx} />}>
<DataGridHeader />
<DataGridBody />
</DataGridRoot>
</DataGridContextMenu>
</DataGridProvider>;DataGridContextMenu must be inside DataGridProvider
It reads grid state through the same selector hooks that your own components use. Mounting
it outside DataGridProvider throws immediately.
Right-click selects the cell first
Right-clicking a cell makes it the active cell with a single-cell selection before the menu opens. That is deliberate: the menu's copy, paste, and row operations all act on the right-clicked cell, so they work even when the cell was not previously selected.
The renderHeaderMenu slot of DataGridRoot renders at the inline-end of each header cell, as a ghost
chevron button on hover, and opens the same menu that a right-click gives you. Per-column
pinnable: false, or grid-wide enableColumnPinning={false}, disables pin and unpin.
A soft directional shadow marks the pinned boundary. The last left-pinned column casts a shadow to the right, and the first right-pinned column casts one to the left, whenever content is actually scrolled beneath it. The grid computes this from the pinned group as a whole, not per column.
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.
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.
Row markers
A pinned-left marker column sits entirely outside the data column index space. It is separate
from the columns array, and reorder and resize do not affect it. Set rowMarkers on
DataGridProvider (or DataGrid). The default is "none":
Pick "both" and hover a row: the row number is replaced by a checkbox while hovered or selected.
<DataGridProvider rowMarkers="both" {...grid} columns={columns}>| Mode | Shows |
|---|---|
"none" | No marker column (default). |
"number" | The 1-based view row index, non-interactive. |
"checkbox" | A per-row checkbox that drives the rows selection channel, plus a select-all checkbox in the marker header. |
"both" | The row number, replaced by the checkbox on hover or when the row is selected (a group-hover pattern). The number is never gone. The checkbox only sits in front of it at the moment the checkbox is actionable. |
Marker press and drag, in "checkbox" and "both" modes, selects a contiguous multi-row range
the same way that header press and drag selects columns. See
Selection & keyboard. The aria-labels of the marker
column (markers.selectAll and markers.selectRow) are translatable through labels. See
i18n.
Try all four modes alongside every other toggle in the playground.
Custom markers
DataGridRoot and DataGrid accept two render slots that replace the marker column's content
while keeping its chrome and gestures:
renderMarkerreplaces the built-in row number/checkbox in every marker cell. It receives the 0-basedviewRowIndexplus two independent selection signals:isRowChannelSelected(the rows channel only, so a cell click never flips it) andisCellSelected(the cell or column channel covers at least one cell of the row). A renderer can therefore tell "this row is selected" apart from "cells in this row are selected" and render them differently.renderMarkerHeaderreplaces the select-all checkbox in the marker header. It receives theallSelectedstate ("checked","indeterminate","unchecked"). In"number"mode there is no select-all checkbox, so the renderer replaces the blank header cell instead.
The rowMarkers mode still sets the track width ("number" 44px, "checkbox" 36px, "both"
56px), and "none" renders no marker column at all, so neither renderer is ever called. The
cell's own press and drag row-selection gesture stays on the wrapper: with a custom renderer,
clicking or dragging a marker cell still selects rows.
import { Check, Circle, List, ListChecks, Minus } from "lucide-react";
import { useDataGridActions, type MarkerCellRenderer, type MarkerHeaderRenderer } from "@/components/data-grid/data-grid";
import { Button } from "@/components/ui/button";
const renderMarker: MarkerCellRenderer = ({ isRowChannelSelected, isCellSelected }) => (
<span className="flex size-5 items-center justify-center">
{isRowChannelSelected ? (
<Check className="size-3.5 text-muted-foreground" aria-hidden="true" />
) : isCellSelected ? (
<Minus className="size-3.5 text-muted-foreground" aria-hidden="true" />
) : (
<Circle className="size-3.5 text-muted-foreground" aria-hidden="true" />
)}
</span>
);
function SelectAllMarker({ allSelected }: { allSelected: "checked" | "indeterminate" | "unchecked" }) {
const { setAllRowsSelected } = useDataGridActions();
const checked = allSelected === "checked";
return (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={checked ? "Unselect all rows" : "Select all rows"}
onClick={() => setAllRowsSelected(!checked)}
>
{checked ? <ListChecks aria-hidden="true" /> : <List aria-hidden="true" />}
</Button>
);
}
const renderMarkerHeader: MarkerHeaderRenderer = (ctx) => <SelectAllMarker {...ctx} />;Define both renderers at module scope (stable identity, the same rule as getRowClassName): a
function defined in the component body would change identity on every render and re-render every
marker cell on every root update.
The built-in checkboxes carry the translatable labels.markers.* aria-labels. A custom renderer
with interactive content must supply its own accessible name on that content.
The marker has three states: row selected (check), only cells of the row selected (minus), or neither (circle). Pressing or dragging a marker cell selects the row; a plain cell click selects that cell and clears the row selection (Ctrl+click adds a range without clearing it).
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. When the sum of
widths already meets or exceeds the container, columns render at their base size, and the grid
scrolls horizontally as usual. A manual resize, by dragging the handle, 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, setColumnOrder, setColumnPin, and
setColumnHidden.
Persisting a user's layout
setColumnWidth, 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?: ColumnLayout; // seeds widths/order/pins/hidden ONCE at mount
onColumnLayoutChange?: (next: ColumnLayout) => void; // fires once per committed changeconst [layout, setLayout] = useState(() => {
const saved = localStorage.getItem("my-grid-layout");
return saved ? (JSON.parse(saved) as ColumnLayout) : undefined;
});
<DataGridProvider
{...grid}
columns={columns}
defaultColumnLayout={layout}
onColumnLayoutChange={(next) => localStorage.setItem("my-grid-layout", JSON.stringify(next))}
>onColumnLayoutChange fires once per resize commit (a drag release or an autosize, never per
drag frame), per reorder drop, per pin or unpin, and per show or hide. See
Events & state
for the full payload shape and firing semantics (that section also covers the live, per-drag-frame
onColumnResizing). defaultColumnLayout is NOT a controlled prop. The grid reads it once at
mount, so a later change to the value you pass does nothing. Always pair it with
onColumnLayoutChange, and never expect it to react on its own.
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 generic of the hook (useDataGridVisibleColumns<Employee>()) to
get back ColumnDef<Employee, unknown>[] instead, so accessorFn and setValue are typed
against Employee rather than unknown.
Pinning rows, for a sticky totals band at the top or bottom, uses the same sticky, frozen-edge machinery. See Pinned rows.