gridcn1.2.2

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.

Name
Email
Age
Role
Score
Silent Tiger
user0@example.com
63
Viewer
4
Quick Lion
user1@example.com
67
Viewer
89
Swift Wolf
user2@example.com
40
Manager
80
Eager Lion
user3@example.com
50
User
51
Eager Tiger
user4@example.com
45
Viewer
83
Silent Wolf
user5@example.com
24
User
15
Swift Lion
user6@example.com
34
Viewer
62
Eager Lion
user7@example.com
21
Manager
42
Eager Eagle
user8@example.com
25
Editor
35
Bold Tiger
user9@example.com
19
Editor
52
Bold Bear
user10@example.com
42
User
28
Swift Wolf
user11@example.com
47
Manager
9
Swift Bear
user12@example.com
41
Viewer
45
Eager Wolf
user13@example.com
35
Editor
44
Swift Eagle
user14@example.com
65
Viewer
51
Quick Lion
user15@example.com
22
Manager
28
Bold Wolf
user16@example.com
30
Editor
76
Swift Eagle
user17@example.com
19
Admin
26
Eager Eagle
user18@example.com
35
Manager
70
Eager Bear
user19@example.com
33
Editor
67
Silent Lion
user20@example.com
48
Manager
89
Silent Tiger
user21@example.com
62
Manager
40
Eager Tiger
user22@example.com
51
Manager
82
Silent Lion
user23@example.com
26
Admin
16
Bold Eagle
user24@example.com
26
Admin
38
Bold Lion
user25@example.com
49
Viewer
3
Quick Tiger
user26@example.com
26
Viewer
41
Bold Eagle
user27@example.com
50
Editor
10
Swift Tiger
user28@example.com
55
Editor
6
Silent Lion
user29@example.com
35
Viewer
42
0
Average
0
0 rows
0
Total
0
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 sortState and filterState of 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 readOnly explicitly overrides it (readOnly: false or a predicate). See the demo's Score and Age columns 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-shadow token 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.

On this page