Pinned rows (engine prop)
Pass pinnedTopRows / pinnedBottomRows arrays straight to <SvGrid>. Sticky cells, same table, same column schema.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Rows & Cells). See the SvGrid documentation for the full API.
About this example
Pinned rows as an engine feature of the Svelte 5 data grid. Pass arrays of rows to pinnedTopRows and pinnedBottomRows and they render inside the same table with position: sticky cells, sharing the column schema, widths, pinning, cellClass and format with the regular rows. No second grid, no stacking; the demo pins account totals at the top and a filtered summary at the bottom.
Pass arrays of TData to the grid's pinnedTopRows and pinnedBottomRows props and the rows render inside the same table with position: sticky cells. They share the column schema (widths, pin, cellClass, format) with the regular rows; no second grid, no stacking, no DOM gymnastics.
Different from demo 107 (which stacks three SvGrid instances - the pure user-land pattern). This is the engine feature.
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: id (Account), account (Name), region (Region), industry (Industry), arr (ARR), seats (Seats), expansion (Expansion), healthScore (Health)
SvGridApi methods called: api.getDisplayedRows()
Frequently asked questions
How do I pin a row to the top?
Put the row object in the pinnedTopRows array; pinnedBottomRows works the same for the bottom. The rows use the same TData shape as data, so a totals row is just an object whose fields hold the totals.
Do pinned rows scroll with the data?
No. Their cells are sticky, so they stay in view while the data rows scroll, and they stay aligned with the columns because they live in the same table.
Can pinned rows react to filters?
Yes. Recompute the array from api.getDisplayedRows() in onFiltersChange and the bottom summary follows the visible rows.
Related documentation
Related articles
- $bindable Props for Grid Controls in Svelte 5 - Two-way binding for grid chrome - search boxes, page-size selectors, density toggles - using $bindable to keep parent state and child input in sync without the usual wiring boilerplate.
- Snippets vs Slots in Svelte 5 - Svelte 5 replaces slots with snippets - a first-class value you can pass as a prop, store in a variable, or select at runtime. Here is what changed, why it matters for grid cell rendering, and where the edge cases bite.
- Runes vs Stores in Svelte 5 - When to Use Which - Svelte 5 runes and stores are not competitors - they solve different problems. Here is a concrete breakdown of when $state wins, when writable still earns its place, and how to mix both without subtle bugs.
Source code (108-pinned-rows-engine.svelte)
<script lang="ts">
/**
* 108. Pinned rows - engine-level (`pinnedTopRows` / `pinnedBottomRows`)
* ---------------------------------------------------------------------
* Pass arrays of `TData` to the grid's `pinnedTopRows` and
* `pinnedBottomRows` props and the rows render inside the same table
* with `position: sticky` cells. They share the column schema (widths,
* pin, cellClass, format) with the regular rows; no second grid, no
* stacking, no DOM gymnastics.
*
* Different from demo 107 (which stacks three SvGrid instances - the
* pure user-land pattern). This is the engine feature.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
type SvGridApi,
} from '@svgrid/grid'
type Row = {
id: string
account: string
region: 'Americas' | 'EMEA' | 'APAC'
industry: 'SaaS' | 'Retail' | 'Manufacturing' | 'Healthcare' | 'Finance'
arr: number
seats: number
expansion: number
healthScore: number
}
let prng = 0xBADA55
function rand() { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }
function pick<T>(a: readonly T[]): T { return a[Math.floor(rand() * a.length)]! }
function int(min: number, max: number) { return Math.floor(min + rand() * (max - min + 1)) }
const REGIONS = ['Americas', 'EMEA', 'APAC'] as const
const INDUSTRIES = ['SaaS', 'Retail', 'Manufacturing', 'Healthcare', 'Finance'] as const
const NAMES = ['Helios', 'Vertex', 'Atlas', 'Quantum', 'Stellar', 'Apex', 'Crescent', 'Sigma',
'Pioneer', 'Aurora', 'Granite', 'Cobalt', 'Meridian', 'Polaris', 'Sentinel',
'Tessera', 'Cascade', 'Beacon', 'Wavelength', 'Lumen', 'Echo', 'Cipher', 'Nimbus']
let rows = $state<Row[]>(Array.from({ length: 200 }, (_, i) => ({
id: `ACC-${(3000 + i).toString()}`,
account: `${pick(NAMES)} ${pick(['Labs', 'Group', 'Industries', 'Networks', 'Systems'])}`,
region: pick(REGIONS),
industry: pick(INDUSTRIES),
arr: int(8_000, 520_000),
seats: int(3, 240),
expansion: int(0, 180_000),
healthScore: int(18, 99),
})))
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
let api = $state<SvGridApi<typeof features, Row> | null>(null)
// ---- Pinned-top rows: live aggregates over the whole dataset ---------
const totals = $derived.by<Row[]>(() => {
const arr = rows.reduce((s, r) => s + r.arr, 0)
const seats = rows.reduce((s, r) => s + r.seats, 0)
const expansion = rows.reduce((s, r) => s + r.expansion, 0)
const avgHealth = rows.length ? rows.reduce((s, r) => s + r.healthScore, 0) / rows.length : 0
const benchmark: Row = {
id: '- BENCHMARK',
account: 'Industry benchmark (Q2 2026)',
region: 'EMEA', industry: 'SaaS',
arr: 180_000, seats: 95, expansion: 45_000, healthScore: 72,
}
const total: Row = {
id: '⌃ TOTALS',
account: `All ${rows.length} accounts`,
region: 'Americas', industry: 'SaaS',
arr, seats, expansion, healthScore: Math.round(avgHealth),
}
return [total, benchmark]
})
// ---- Pinned-bottom rows: filter-aware page totals --------------------
let displayedSnapshot = $state<readonly Row[]>([])
$effect(() => { displayedSnapshot = rows })
$effect(() => {
const id = setInterval(() => {
displayedSnapshot = api?.getDisplayedRows() ?? rows
}, 400)
return () => clearInterval(id)
})
const pageTotals = $derived.by<Row[]>(() => {
const set = displayedSnapshot
const arr = set.reduce((s, r) => s + r.arr, 0)
const seats = set.reduce((s, r) => s + r.seats, 0)
const expansion = set.reduce((s, r) => s + r.expansion, 0)
const avgHealth = set.length ? set.reduce((s, r) => s + r.healthScore, 0) / set.length : 0
return [{
id: '⌄ PAGE',
account: `Visible page (n = ${set.length})`,
region: 'Americas', industry: 'SaaS',
arr, seats, expansion, healthScore: Math.round(avgHealth),
}]
})
// ---- Toggle UI -------------------------------------------------------
let showTop = $state(true)
let showBottom = $state(true)
const pinnedTopRows = $derived(showTop ? totals : [])
const pinnedBottomRows = $derived(showBottom ? pageTotals : [])
const cellHealthClass = (ctx: { getValue: () => unknown }) => {
const v = Number(ctx.getValue())
return v >= 75 ? 'health-good' : v >= 50 ? 'health-warn' : 'health-bad'
}
const columns: GridColumns<Row> = [
{ field: 'id', header: 'Account', width: 130, editable: false },
{ field: 'account', header: 'Name', width: 230, editable: false },
{ field: 'region', header: 'Region', width: 100, editable: false },
{ field: 'industry', header: 'Industry', width: 130, editable: false },
{ field: 'arr', header: 'ARR', width: 140, align: 'right', editable: false,
format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },
{ field: 'seats', header: 'Seats', width: 90, align: 'right', editable: false },
{ field: 'expansion', header: 'Expansion', width: 130, align: 'right', editable: false,
format: { type: 'number', options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } } },
{ field: 'healthScore', header: 'Health', width: 100, align: 'right', editable: false,
cellClass: cellHealthClass },
]
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="info shrink-0">
<p>
Pass <code>pinnedTopRows</code> and <code>pinnedBottomRows</code> to <code><SvGrid></code> -
they render inside the same table with sticky cells. <strong>Try scrolling</strong> the grid:
the <span class="tag-top">↑ TOTALS</span> and <span class="tag-top">↑ BENCHMARK</span> rows stay
anchored at the top, and the <span class="tag-bot">↓ PAGE</span> row sticks to the bottom and
updates as filters narrow the visible set.
</p>
<div class="toggles">
<label><input type="checkbox" bind:checked={showTop} /> Top pinned (totals + benchmark)</label>
<label><input type="checkbox" bind:checked={showBottom} /> Bottom pinned (page totals)</label>
</div>
</div>
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
pinnedTopRows={pinnedTopRows}
pinnedBottomRows={pinnedBottomRows}
filterMode="menu"
selectionMode="cell"
enableInlineEditing={false}
enableCellSelection={true}
rowHeight={32}
containerHeight="100%"
fitColumns={true}
onApiReady={(next) => (api = next)}
/>
</div>
</section>
<style>
.info {
border: 1px solid var(--sg-border, #e2e8f0);
background: color-mix(in oklab, var(--sg-accent, #6366f1) 5%, transparent);
border-radius: 8px; padding: 10px 14px;
font-size: 13px; color: var(--sg-fg, #0f172a);
}
.info p { margin: 0 0 6px; }
.info code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: color-mix(in oklab, var(--sg-accent, #6366f1) 12%, transparent);
color: var(--sg-accent, #4338ca);
padding: 1px 5px; border-radius: 3px; font-size: 12px;
}
.toggles { display: flex; gap: 14px; font-size: 12px; }
.toggles label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.toggles input { accent-color: var(--sg-accent, #6366f1); }
.tag-top, .tag-bot {
display: inline-block; padding: 0 6px; border-radius: 3px;
font-size: 11px; font-weight: 800; font-family: ui-monospace, monospace;
}
.tag-top { background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff); }
/* The bottom tag keeps its green so top vs bottom stay tellable apart
whatever the theme accent happens to be. */
.tag-bot { background: #10b981; color: #fff; }
:global(td.health-good) { color: #059669; font-weight: 700; }
:global(td.health-warn) { color: #d97706; font-weight: 600; }
:global(td.health-bad) { color: #dc2626; font-weight: 700; }
</style>More Rows & Cells examples
- Managed row dragging (grid-to-grid) - Reorder rows by dragging their grip, or move a row from one grid into another - both grids share a rowDragGroup, so the row leaves the source and lands in the target. The grid mutates its own data on drop and fires onRowDragEnd on the receiver.
- External drop zones (row drag) - Drag a row out of the grid onto any element - an Archive or Delete bucket - via the rowDropZone action. The row leaves the grid and the zone's onDrop handles it. In-grid reorder still works.
- Custom cells + themes - Avatars, sparklines, progress bars, density toggle, dark mode, full a11y.
- Sparkline cells - In-cell mini charts as a first-class column type: set `sparkline` on a number-array column and the grid paints an inline SVG. Line, area, bar (with +/- coloring), and win/loss - no chart library, no custom snippet.
- Conditional formatting (engine) - Excel-style value-driven cell coloring as a declarative `conditionalFormats` engine prop: gradient heat maps (alpha ramp, zero-centred, banded, column-comparison), in-cell data bars, icon sets, and predicate rules - scoped per column, no per-cell snippet.