Row pinning (top / bottom)
Pin specific rows to the top or bottom of the grid so they stay visible while the rest of the data scrolls. Typical use cases:
- A totals or aggregate row anchored at the top of a long ledger.
- An industry benchmark or target row for at-a-glance comparison.
- A page totals row at the bottom that recomputes as filters narrow the visible set.
- A headline metric (e.g. "Closing balance") that should never scroll out of view.
Open the live example: Pinned rows (engine prop) (Rows & Cells)
Props
<SvGrid> accepts two props:
The examples on this page run against these rows:
<script lang="ts">
import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'
type Person = {
id: number
name: string
department: string
city: string
age: number
salary: number
}
const people: Person[] = [
{ id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },
{ id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
{ id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },
{ id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },
{ id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },
]
const columns: GridColumns<Person> = [
{ field: 'name', header: 'Name', width: 190 },
{ field: 'department', header: 'Department', width: 150 },
{ field: 'city', header: 'City', width: 130 },
{ field: 'age', header: 'Age', width: 80 },
{ field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },
]
</script>
pinnedTopRows?: ReadonlyArray<TData>
pinnedBottomRows?: ReadonlyArray<TData>
Each is an array of your own row objects (same TData type as data). The grid renders them inside the same table as the regular rows, with position: sticky so they track the scroll. They share the column schema (widths, pinning, format, cellClass) - you do not redefine columns.
Quick start
<script lang="ts">
import { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'
type Row = { id: string; account: string; arr: number; seats: number }
let rows = $state<Row[]>([ /* ...200 accounts... */ ])
// Pinned-top: a single "totals" row computed from `rows`.
const totals = $derived<Row[]>([
{
id: '⌃ TOTALS',
account: `All ${rows.length} accounts`,
arr: rows.reduce((s, r) => s + r.arr, 0),
seats: rows.reduce((s, r) => s + r.seats, 0),
},
])
const features = tableFeatures({ rowSortingFeature })
const columns: ColumnDef<typeof features, Row>[] = [
{ field: 'id', header: 'Account', width: 130, editable: false },
{ field: 'account', header: 'Name', width: 240, editable: false },
{ field: 'arr', header: 'ARR', width: 140, align: 'right',
format: { type: 'number', options: { style: 'currency', currency: 'USD' } } },
{ field: 'seats', header: 'Seats', width: 100, align: 'right' },
]
</script>
<SvGrid
data={rows}
columns={columns}
features={features}
pinnedTopRows={totals}
/>
The totals row sticks under the header and stays in view while the user scrolls.
Filter-aware "page totals" at the bottom
The grid exposes api.getDisplayedRows() - the post-filter / post-sort / post-pagination row set. Combine it with pinnedBottomRows for a "page total" that updates as filters narrow the data.
<script lang="ts">
let api = $state<SvGridApi<typeof features, Row> | null>(null)
let snapshot = $state<readonly Row[]>([])
// Resync the snapshot whenever filters / sorts likely changed. A
// 400 ms poll keeps the demo simple; production code can hook the
// `onFiltersChange` / `onSortingChange` events instead.
$effect(() => {
const id = setInterval(() => { snapshot = api?.getDisplayedRows() ?? rows }, 400)
return () => clearInterval(id)
})
const pageTotals = $derived<Row[]>([{
id: '⌄ PAGE',
account: `Visible page (n = ${snapshot.length})`,
arr: snapshot.reduce((s, r) => s + r.arr, 0),
seats: snapshot.reduce((s, r) => s + r.seats, 0),
}])
</script>
<SvGrid
data={rows}
columns={columns}
features={features}
pinnedBottomRows={pageTotals}
onApiReady={(next) => (api = next)}
/>
Multiple pinned rows
Pass more than one row in either array; the grid stacks them. The first row sticks closest to the header (top) or to the bottom edge (bottom).
<SvGrid
data={rows}
columns={columns}
features={features}
pinnedTopRows={[totals, benchmark, target]}
pinnedBottomRows={[pageTotals, runningBalance]}
/>
The built-in CSS supports stacking up to 4 rows per side. For larger stacks, override the CSS variables yourself:
.sv-grid-pinned-row-top[data-pinned-index="4"] td {
top: calc(var(--sg-thead-h, 36px) + var(--sg-pinned-row-h, 32px) * 4);
}
How pinned rows behave
| Behavior | Regular rows | Pinned rows |
|---|---|---|
| Inline editing | Yes (when enableInlineEditing) |
No |
| Selection checkbox | Yes (when enabled) | No (cell rendered empty) |
| Cell selection rectangle | Yes | No (cells skipped) |
| Sorting | Yes | No (pinned rows are in a separate <tbody>) |
| Filtering | Yes | No (pinned rows are not filtered) |
| Fill handle | Yes | No |
cellClass |
Yes | Yes (a function gets row: null and the pinned value) |
format, formatter |
Yes | Yes |
align |
Yes | Yes |
| Column pinning (left/right) | Yes | Yes |
Pinned rows are read-only by design - they represent aggregates or annotations, not transactional data. For an editable band at the top, freeze the grid's own rows instead: see below.
Frozen rows (frozenRows)
frozenRows={N} keeps the first N rows under the header while the body
scrolls, Excel's Freeze Panes for the row half (columnPinning is the
column half). Unlike pinned rows they are the grid's own rows: numbered
1..N, editable, selectable, sortable with the rest, dragged taller with
rowResize. Under virtualization they are always rendered and the
window skips them.
<SvGrid data={people} {columns} frozenRows={2} showRowNumbers enableInlineEditing containerHeight={220} />
api.setOption('frozenRows', n) changes it at runtime and undefined
clears it; the spreadsheet shell's Freeze and Unfreeze buttons do exactly
that, at the active cell, and keep the setting per sheet.
Styling
Pinned rows expose two CSS variables on the grid shell that you can override:
.sv-grid-shell {
--sg-pinned-bg: color-mix(in oklab, #6366f1 4%, #ffffff);
--sg-pinned-border: color-mix(in oklab, #6366f1 24%, transparent);
}
For per-row styling, target the row's class directly:
.sv-grid-pinned-row-top { font-weight: 700; }
.sv-grid-pinned-row-bottom { background: #ecfdf5; }
Each row also carries data-pinned-row="top" | "bottom" and data-pinned-index="0..N" so you can paint distinct ranks differently.
More examples
Pinned rows (top / bottom)
Frozen "Account totals" row at the top + sticky "Page totals" at the bottom that reacts to filters. Right-click any row to pin it.
Open the live example: Pinned rows (top / bottom) (Rows & Cells)
Pinned rows and row numbers
Pinned rows sit outside the scrolling body, so they survive sorting and paging. Row numbers count the scrolling rows only, which is what makes a pinned total read as a total rather than as row 6.
<SvGrid data={people} {columns} showRowNumbers sortable pageable pageSize={3} />
See also
- Demo 108: Pinned rows (engine prop)
- Demo 107: Pinned rows (stacked-grids user-land pattern)
api.getDisplayedRows()- drives filter-aware page totals.
Live examples
- Pinned rows (engine prop) - Pass pinnedTopRows / pinnedBottomRows arrays straight to <SvGrid>. Sticky cells, same table, same column schema.
- Pinned rows (top / bottom) - Frozen "Account totals" row at the top + sticky "Page totals" at the bottom that reacts to filters. Right-click any row to pin it.