gridcn

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.

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.

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.

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.

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.

Name
Role
Score
1
Silent Tiger
Viewer
4
2
Quick Lion
Viewer
89
3
Swift Wolf
Manager
80
4
Eager Lion
User
51
5
Eager Tiger
Viewer
83
6
Silent Wolf
User
15
7
Swift Lion
Viewer
62
8
Eager Lion
Manager
42
9
Eager Eagle
Editor
35
10
Bold Tiger
Editor
52
<DataGridProvider rowMarkers="both" {...grid} columns={columns}>
ModeShows
"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:

  • renderMarker replaces the built-in row number/checkbox in every marker cell. It receives the 0-based viewRowIndex plus two independent selection signals: isRowChannelSelected (the rows channel only, so a cell click never flips it) and isCellSelected (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.
  • renderMarkerHeader replaces the select-all checkbox in the marker header. It receives the allSelected state ("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).

Name
Score
Silent Tiger
4
Quick Lion
89
Swift Wolf
80
Eager Lion
51
Eager Tiger
83
Silent Wolf
15
Swift Lion
62
Eager Lion
42
Eager Eagle
35
Bold Tiger
52

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 change
const [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.

On this page