gridcn

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 { rowBands } = useDataGridPinnedRows({ topRows: [averagesRow], bottomRows: [totalsRow] });

<DataGridProvider {...grid} columns={columns} rowBands={rowBands}>
  <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 rowBands prop lives on the provider, so the spec has to exist before it. This mirrors the shape of useDataGridPresence and useDataGridFill:

"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 { rowBands } = useDataGridPinnedRows({ topRows: [totalsRow] });

  return <DataGrid data={data} columns={columns} getRowId={getRowId} rowBands={rowBands} />;
}

Composing with core's DataGrid convenience wrapper

<DataGrid rowBands={rowBands}> works the same way as <DataGridProvider>. Both accept the prop and forward it straight through to the same seam.

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, Tab, 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.

Computing a totals row

const TOTALS_SPECS = { 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 `rowBands` only changes when the totals actually do.
  const onTotalsChange = useCallback((row: Record<string, unknown>) => {
    setTotals((prev) => ({ ...prev, ...row }));
  }, []);

  const bottomRows = useMemo(() => [totals], [totals]);
  const { rowBands } = useDataGridPinnedRows({ bottomRows });

  return (
    <DataGridProvider {...grid} columns={columns} rowBands={rowBands}>
      <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.

useDataGridAggregate reads the store, so it only works inside <DataGridProvider>. rowBands is a prop of the provider, evaluated in the parent. DataGridAggregateReporter bridges the two: it renders as a plain child under the provider, calls the hook, and reports the result through onChange into a useState in the parent — that state is what feeds topRows/bottomRows.

Keep the band arrays referentially stable

useDataGridPinnedRows returns a stable rowBands 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]) makes rowBands churn every render, and the grid dev-warns on that. Memoize the arrays, as in the example above, so they only change when the band contents change. The band rows themselves still need no getRowId: pinned cells resolve through each column's accessorKey/accessorFn exactly like data rows.

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:

const specs = {
  name: (_values, rows) => `${rows.length} rows`,
  score: (values) => Math.max(...(values as number[]), 0),
};

Manual computation

For anything useDataGridAggregate doesn't cover, compute the band's rows yourself from grid.data in a useMemo — this is the same pattern as before the hook existed. 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.

Frozen-edge shadow

Each band casts a soft shadow on its scroll-facing edge, shown only once a data row has actually scrolled beneath it. This uses 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 (1), 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