Pinned rows
Sticky top/bottom row bands (e.g. a totals row) via the same sticky machinery as the header.
Pinned rows live in the separate data-grid-pinned-rows add-on: a top band under the header, a
bottom band at the viewport's bottom edge, both scrolling the data rows underneath them.
Filter the Role column via the toolbar: the pinned Average and Total rows recompute over only the rows the filter leaves visible, not the full dataset.
pnpm dlx shadcn@latest add @gridcn/data-grid-pinned-rows
const { plugin } = useDataGridPinnedRows({ topRows: [averagesRow], bottomRows: [totalsRow] });
<DataGridProvider {...grid} columns={columns} plugins={[...grid.plugins, plugin]}>
<DataGridRoot>
<DataGridHeader />
<DataGridBody />
</DataGridRoot>
</DataGridProvider>;Wiring it up
useDataGridPinnedRows() is a single hook. Call it in your own component ABOVE where you
render <DataGridProvider>. The plugins prop lives on the provider, so the plugin has to
exist before it. The same composition works with the core's DataGrid convenience wrapper (see
the note on Fill handle).
"use client";
import { DataGrid } from "@/components/data-grid/data-grid";
import { useDataGridPinnedRows } from "@/components/data-grid-pinned-rows/data-grid-pinned-rows";
function MyGrid() {
const { plugin } = useDataGridPinnedRows({ topRows: [totalsRow] });
return <DataGrid data={data} columns={columns} getRowId={getRowId} plugins={[plugin]} />;
}API
useDataGridPinnedRows({ topRows?, bottomRows? }) accepts a separate array from data,
not indices into it, for each band. Each band renders every row in the array, in order, so a
band can stack more than one row. The demo above pins an averages row on top and a totals row
on the bottom. Pinned rows keep the viewIndex, sort, filter, and selection machinery
untouched.
Pinned rows are display-only
- Never sort or filter. They render exactly in the order you pass them, independent of
the
sortStateandfilterStateof the grid. - Do not participate in range selection or the rows-channel. They are excluded from
selection, the marker checkbox column, and Ctrl+A / Shift+Space. A range drawn across the whole view stops at the last real data row. - Are readOnly by default. Editing a pinned cell does nothing unless the column's own
readOnlyexplicitly overrides it (readOnly: falseor a predicate). See the demo'sScoreandAgecolumns for a read-only totals band. - Are not keyboard-navigable. Arrow keys and Ctrl+Arrow jumps move only within data rows. A pinned row is never the active cell. Click-to-select also does nothing on a pinned cell.
Cells render through the same DataGridCell and cell-type pipeline as data rows: the same
column alignment, the same text, number, date, and other formatting, and the same
subgrid column tracks. So a pinned row lines up with its columns pixel-for-pixel, including
pinned-left and pinned-right columns and column resize.
Pinned rows go through the grid's getRowId, so ctx.rowId in a pinned cell is that id. If
getRowId throws on a pinned row or returns an empty or non-string value, the row gets a band
id instead. The band id is __pinned-top-<index> or __pinned-bottom-<index>, where
<index> is the row's position in its band.
Computing a totals row
const TOTALS_SPECS: AggregateSpecs = { score: "sum", age: "avg" };
function MyGrid() {
const grid = useDataGridState(initialRows, { getRowId: (r) => r.id });
const [totals, setTotals] = useState({ id: "__totals__" });
// Stable identity: keep `onChange` out of the inline-closure pattern, and keep the band
// arrays memoized so the plugin only changes when the totals actually do.
const onTotalsChange = useCallback((row: Record<string, unknown>) => {
setTotals((prev) => ({ ...prev, ...row }));
}, []);
const bottomRows = useMemo(() => [totals], [totals]);
const { plugin } = useDataGridPinnedRows({ bottomRows });
const plugins = useMemo(() => [...grid.plugins, plugin], [grid.plugins, plugin]);
return (
<DataGridProvider {...grid} columns={columns} plugins={plugins}>
<DataGridAggregateReporter specs={TOTALS_SPECS} onChange={onTotalsChange} />
<DataGridRoot>
<DataGridHeader />
<DataGridBody />
</DataGridRoot>
</DataGridProvider>
);
}useDataGridAggregate(specs) reduces each columnId in specs over the grid's filtered/sorted/
searched viewIndex. It returns a { [columnId]: value } object: the row shape
useDataGridPinnedRows's topRows/bottomRows expect, since pinned cells resolve values
through each column's accessorKey/accessorFn like data rows do. A reducer is "sum",
"avg", "min", "max", "count", or a custom (values, rows) => unknown function.
Numeric reducers skip null/undefined values instead of producing NaN. count counts
non-empty values. Pass scope: "all" to reduce over the raw data array instead, ignoring
the active filter. If a specs key matches no column id, the aggregate ignores it and a dev
warning fires once.
Because the hook reads the store and the plugins prop lives on the provider,
DataGridAggregateReporter
bridges the two: it renders as a plain child under the provider and reports the result through
onChange into parent state, which is what feeds topRows/bottomRows.
Under client-mode pagination, a totals band is page-local
The grid's data is the current page's slice. So useDataGridAggregate sums only that page's
rows, and scope: "all" does not help. The slice is the grid's whole data. Fetch
dataset-wide totals from your server. Or compute them from your full dataset outside the grid,
and pin the result as a static band row. See Pagination.
Not supported on a lazy grid
On a data-grid-lazy grid, unloaded rows are undefined holes. The aggregate skips them (a
dev warning fires once), so the band covers loaded rows only. Use server-side totals, or
compute totals outside the grid from the full dataset. See
Lazy loading.
Keep the band arrays referentially stable
useDataGridPinnedRows returns a stable plugin reference only while the topRows and
bottomRows arrays you pass keep the same identity. A fresh array on every render (like the
inline bottomRows: [totals]) rebuilds the plugin every render, and the grid re-resolves
its plugin list on every render. Memoize the arrays, as in the example above, so they only
change when the band contents change. The band rows need no id field: pinned cells resolve
values through each column's accessorKey/accessorFn exactly like data rows, and a row
without an id gets a band id.
Custom reducers
A reducer function receives every resolved value and its source row, unfiltered. Write your
own empty-handling if the built-ins don't fit. The hook is generic over the row type: when
specs carries it (as AggregateSpecs<Row> here, or as the generic at the hook call),
rows is typed:
const specs: AggregateSpecs<Row> = {
name: (_values, rows) => `${rows.length} rows`,
score: (_values, rows) => Math.max(...rows.map((r) => r.score), 0),
};Manual computation
For anything useDataGridAggregate doesn't cover, compute the band's rows yourself from
grid.data in a useMemo. Reducing over grid.data ignores the active filter. Reduce over
useDataGridViewIndex() instead, if the band must match what the filtered view shows.
function computeTotalsRow(rows: readonly Row[]): Row {
return {
id: "__totals__",
name: `${rows.length} rows`,
score: rows.reduce((sum, r) => sum + r.score, 0),
// ...
};
}
const top = useMemo(() => [computeTotalsRow(grid.data)], [grid.data]);Geometry
The height of the pinned-top band shrinks the effective space above the scrollable data rows, like the header. The height of the pinned-bottom band shrinks the effective viewport bottom. Both compose with everything else that shares that viewport:
- Row virtualization.
useDataGridScrollToCell, so keyboard navigation never scrolls a data row under a band.- Column resize and reorder.
- Pinned-left and pinned-right columns. A pinned column's cell stays fixed horizontally inside
a pinned row band, exactly like it does in a data row. Each band casts a soft shadow on its
scroll-facing edge, shown only once a data row has actually scrolled beneath it: the same
--grid-pin-shadowtoken and the same "real content scrolled under it" gate used by pinned columns.
a11y
The aria-rowindex order is: header rows (1 without column groups), pinned-top rows, data rows, then pinned-bottom rows.
aria-rowcount on the grid includes both bands. Pinned cells carry role="gridcell" and the
same data-type and data-readonly attributes as data cells. They carry no tabindex, because
they are not part of roving-tabindex navigation. They also carry no aria-selected, because they
are never part of the selection model.