gridcn1.3.0

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.

Name
Email
Role
Joined
Age
Bio (double-click edge to autosize)
Score
Silent Tiger
user0@example.com
Viewer
2023-07-05
63
Silent Tiger has been a viewer since 2023-07-05.
4
Quick Lion
user1@example.com
Viewer
2021-04-03
67
Quick Lion has been a viewer since 2021-04-03.
89
Swift Wolf
user2@example.com
Manager
2020-10-27
40
Swift Wolf has been a manager since 2020-10-27.
80
Eager Lion
user3@example.com
User
2022-10-08
50
Eager Lion has been a user since 2022-10-08.
51
Eager Tiger
user4@example.com
Viewer
2024-10-07
45
Eager Tiger has been a viewer since 2024-10-07.
83
Silent Wolf
user5@example.com
User
2020-06-01
24
Silent Wolf has been a user since 2020-06-01.
15
Swift Lion
user6@example.com
Viewer
2022-10-18
34
Swift Lion has been a viewer since 2022-10-18.
62
Eager Lion
user7@example.com
Manager
2021-10-07
21
Eager Lion has been a manager since 2021-10-07.
42
Eager Eagle
user8@example.com
Editor
2024-06-11
25
Eager Eagle has been a editor since 2024-06-11.
35
Bold Tiger
user9@example.com
Editor
2024-03-20
19
Bold Tiger has been a editor since 2024-03-20.
52
Bold Bear
user10@example.com
User
2020-04-16
42
Bold Bear has been a user since 2020-04-16.
28
Swift Wolf
user11@example.com
Manager
2022-08-04
47
Swift Wolf has been a manager since 2022-08-04.
9

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.

Click the Name or Score header to sort: a minus means unsorted, an up icon ascending, a down icon descending.
Name
Email
Score
Silent Tiger
user0@example.com
4
Quick Lion
user1@example.com
89
Swift Wolf
user2@example.com
80
Eager Lion
user3@example.com
51
Eager Tiger
user4@example.com
83
Silent Wolf
user5@example.com
15
Swift Lion
user6@example.com
62
Eager Lion
user7@example.com
42
Eager Eagle
user8@example.com
35
Bold Tiger
user9@example.com
52

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 */ />;
FieldMeaning
idThe group id. It is unique across all groups and all columns.
headerThe label: a string or a node.
headerTextPlain text for assistive technology and for the columns menu when header is a node.
childrenColumn ids and nested groups, in the order you list them.
headerClassNameClasses 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 order splits 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 change

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

On this page