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.

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

Frozen rows at the top and bottom of the Svelte 5 data grid, built from three stacked grids. A top grid holds an Account totals row with sum, average and count over the whole dataset, the middle grid scrolls the data, and a bottom grid shows Page totals recomputed from api.getDisplayedRows() as filters change. All three share the column schema and widths, and a right-click pins any data row with a badge.

Excel-style "freeze rows": specific records stay anchored to the top or bottom of the viewport while the user scrolls the rest of the dataset. Two classic uses on screen here:

  • TOP: a sticky "Account totals" header row showing the running SUM / AVG / COUNT for the entire dataset.
  • BOTTOM: a sticky "Page totals" row showing SUM / AVG just for the currently-filtered visible rows. Reacts to column filters.

Pattern: render three <SvGrid responsive={true}> instances stacked vertically - pinned-top (1 row), main (scrollable), pinned-bottom (1 row). They share the same column widths and the same row schema. Right-click any data row to pin it; pinned rows show a small badge.

This is a pure user-land pattern - no library work needed.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

SvGridApi methods called: api.getDisplayedRows()

Frequently asked questions

How do the three grids stay aligned?

They receive the same columns array, so widths, alignment and formats match; the top and bottom grids have one row each and no scrollbar, the middle one scrolls.

How do the page totals react to filters?

onFiltersChange reads api.getDisplayedRows() from the main grid and recomputes the bottom row from those rows, so totals always describe what is visible.

Is there a built-in way?

Yes. The pinnedTopRows and pinnedBottomRows props render pinned rows inside one grid with sticky cells; this stacked version is the option when you need fully independent grids.

Related documentation

Related articles

Source code (107-pinned-rows.svelte)

<script lang="ts">
  /**
   * 107. Pinned rows (top + bottom)
   * -------------------------------
   * Excel-style "freeze rows": specific records stay anchored to the top
   * or bottom of the viewport while the user scrolls the rest of the
   * dataset. Two classic uses on screen here:
   *
   *   - TOP: a sticky "Account totals" header row showing the running
   *     SUM / AVG / COUNT for the entire dataset.
   *   - BOTTOM: a sticky "Page totals" row showing SUM / AVG just for
   *     the currently-filtered visible rows. Reacts to column filters.
   *
   * Pattern: render three `<SvGrid responsive={true}>` instances stacked vertically -
   * pinned-top (1 row), main (scrollable), pinned-bottom (1 row). They
   * share the same column widths and the same row schema. Right-click
   * any data row to pin it; pinned rows show a small badge.
   *
   * This is a pure user-land pattern - no library work needed.
   */
  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' | 'Healthcare' | 'Manufacturing' | 'Finance'
    arr: number
    seats: number
    expansion: number     // additional ARR opportunity
    healthScore: number
    isUserPinned: boolean // shown in the pinned-bottom strip when true
  }

  // Seeded data
  let prng = 0xC0FFEE
  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', 'Healthcare', 'Manufacturing', '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: 80 }, (_, i) => ({
    id: `ACC-${(2000 + 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),
    isUserPinned: i === 7 || i === 19,   // start with 2 user-pinned
  })))

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
  let api = $state<SvGridApi<typeof features, Row> | null>(null)

  // ---- Filter-aware "page" snapshot. We poll on each render via the
  // api's `getDisplayedRows`; whenever the user filters / sorts the main
  // grid, this updates and the bottom pinned row recomputes.
  let displayedSnapshot = $state<readonly Row[]>([])
  let bumpTick = $state(0)
  $effect(() => { displayedSnapshot = rows })   // seed + react to outer updates
  function refreshSnapshot() { displayedSnapshot = api?.getDisplayedRows() ?? rows }

  // ---- Aggregations ---------------------------------------------------
  function agg(set: readonly Row[]) {
    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 > 0 ? set.reduce((s, r) => s + r.healthScore, 0) / set.length : 0
    return { count: set.length, arr, seats, expansion, avgHealth }
  }
  const totals = $derived.by(() => agg(rows))         // for the top-pinned "all accounts" strip
  const pageTotals = $derived.by(() => { bumpTick; return agg(displayedSnapshot) })
  const userPinned = $derived(rows.filter((r) => r.isUserPinned))

  // ---- Synthetic "pinned-top" row: an aggregate, rendered as a single
  // row in its own SvGrid instance so columns/widths line up perfectly.
  const topPinnedData = $derived<Row[]>([{
    id: 'โŒƒ ALL',
    account: `ALL ACCOUNTS  (n = ${totals.count})`,
    region: 'Americas',           // unused for display - replaced by cellRenderer
    industry: 'SaaS',
    arr: totals.arr,
    seats: totals.seats,
    expansion: totals.expansion,
    healthScore: Math.round(totals.avgHealth),
    isUserPinned: false,
  }])

  // ---- Bottom: user-pinned rows + page-total row ----------------------
  const bottomPinnedData = $derived<Row[]>([
    ...userPinned,
    {
      id: 'โŒ„ PAGE',
      account: `VISIBLE PAGE  (n = ${pageTotals.count})`,
      region: 'Americas',
      industry: 'SaaS',
      arr: pageTotals.arr,
      seats: pageTotals.seats,
      expansion: pageTotals.expansion,
      healthScore: Math.round(pageTotals.avgHealth),
      isUserPinned: false,
    },
  ])

  // ---- Toggle row-pin via right-click ---------------------------------
  function toggleUserPin(rowIndex: number) {
    rows = rows.map((r, i) => i === rowIndex ? { ...r, isUserPinned: !r.isUserPinned } : r)
  }

  function onCtx(e: MouseEvent) {
    const cell = (e.target as HTMLElement | null)?.closest<HTMLElement>('td[data-svgrid-row]')
    if (!cell) return
    const idx = Number(cell.dataset.svgridRow)
    if (Number.isFinite(idx)) {
      e.preventDefault()
      toggleUserPin(idx)
    }
  }

  // Cell class helpers
  const cellHealthClass = (ctx: { getValue: () => unknown }) => {
    const v = Number(ctx.getValue())
    return v >= 75 ? 'health-good' : v >= 50 ? 'health-warn' : 'health-bad'
  }

  // ---- Column defs (shared schema, three instances) -------------------
  const baseColumns: GridColumns<Row> = [
    { field: 'id',          header: 'Account',   width: 110, editable: false },
    { field: 'account',     header: 'Name',      width: 220, editable: false },
    { field: 'region',      header: 'Region',    width: 100, editable: false },
    { field: 'industry',    header: 'Industry',  width: 130, editable: false },
    { field: 'arr',         header: 'ARR',       width: 130, 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: 120, 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 },
  ]

  // Re-render bottom strip when the user types in filter inputs.
  $effect(() => {
    const id = setInterval(() => { refreshSnapshot(); bumpTick++ }, 500)
    return () => clearInterval(id)
  })
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="info shrink-0">
    <p>
      Three stacked grids share the same column schema. The
      <strong style="color:var(--sg-accent, #6366f1)">top strip</strong> shows all-accounts totals;
      the <strong style="color:#10b981">bottom strip</strong> shows totals for the visible / filtered set,
      plus any user-pinned rows.
      <span class="info-tip">Right-click any data row to pin it to the bottom.</span>
    </p>
  </div>

  <!-- TOP pinned row ----------------------------------------------- -->
  <div class="pin-strip pin-top">
    <div class="pin-tag pin-tag-top">TOP ยท ALL ACCOUNTS</div>
    <div class="pin-grid">
      <SvGrid responsive={true}
      columnResize
        data={topPinnedData}
        columns={baseColumns}
        features={features}
        filterMode="none"
        selectionMode="none"
        enableInlineEditing={false}
        enableCellSelection={false}
        rowHeight={36}
        containerHeight={70}
        fitColumns={true}
      />
    </div>
  </div>

  <!-- Main (scrollable) grid -------------------------------------- -->
  <div class="flex-1 min-h-0 grid-host"
    oncontextmenu={onCtx}
    role="presentation"
  >
    <SvGrid responsive={true}
      columnResize
      data={rows}
      columns={baseColumns}
      features={features}
      filterMode="menu"
      selectionMode="cell"
      enableInlineEditing={false}
      enableCellSelection={true}
      rowHeight={32}
      containerHeight="100%"
      fitColumns={true}
      onApiReady={(next) => { api = next; refreshSnapshot() }}
      rowClass={(ctx) => ctx.row.isUserPinned ? 'is-user-pinned' : ''}
    />
  </div>

  <!-- BOTTOM pinned: user-pinned + page totals ------------------ -->
  <div class="pin-strip pin-bottom hide-header">
    <div class="pin-tag pin-tag-bottom">
      BOTTOM ยท {userPinned.length > 0 ? `${userPinned.length} PINNED + PAGE` : 'PAGE TOTALS'}
    </div>
    <div class="pin-grid">
      <SvGrid responsive={true}
      columnResize
        data={bottomPinnedData}
        columns={baseColumns}
        features={features}
        filterMode="none"
        selectionMode="none"
        enableInlineEditing={false}
        enableCellSelection={false}
        rowHeight={36}
        containerHeight={Math.min(220, 36 * bottomPinnedData.length + 12)}
        fitColumns={true}
      />
    </div>
  </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; }
  .info-tip { color: var(--sg-muted, #64748b); font-style: italic; margin-left: 4px; }

  .pin-strip {
    position: relative;
    border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: 8px;
    overflow: hidden;
    background: var(--sg-bg, #fff);
  }
  .pin-strip.pin-top    { border-top: 3px solid var(--sg-accent, #6366f1); }
  .pin-strip.pin-bottom { border-bottom: 3px solid #10b981; }
  .pin-tag {
    position: absolute; top: 8px; left: 8px; z-index: 10;
    padding: 2px 8px; border-radius: 4px;
    font-size: 9px; font-weight: 800; letter-spacing: 0.08em;
    text-transform: uppercase;
    pointer-events: none;
  }
  .pin-tag-top    { background: var(--sg-accent, #6366f1); color: var(--sg-on-accent, #fff); }
  .pin-tag-bottom { background: linear-gradient(135deg, #10b981, #059669); color: #fff; }
  .pin-grid { overflow: hidden; }
  /* Bottom strip hides the duplicated header - the top strip's header
     already serves as the column reference. */
  .hide-header .pin-grid :global(thead) { display: none; }

  /* Visual highlight for the rows the USER pinned (still in the main grid). */
  :global(tr.is-user-pinned) {
    background: color-mix(in oklab, #10b981 4%, transparent) !important;
  }
  :global(tr.is-user-pinned td:first-child::before) {
    content: '๐Ÿ“Œ';
    margin-right: 4px; font-size: 10px;
    vertical-align: middle;
  }

  /* Health column tint -------------------------------------------- */
  :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; }

  /* Aggregator rows: bold the pinned-row's "summary" feel. */
  .pin-strip :global(td) {
    background: linear-gradient(180deg, color-mix(in oklab, var(--sg-accent, #6366f1) 4%, transparent), transparent) !important;
    font-weight: 600;
  }
  .pin-strip.pin-bottom :global(td) {
    background: linear-gradient(180deg, color-mix(in oklab, #10b981 4%, transparent), transparent) !important;
  }
  /* Phone: the strips hide overflow (rounded corners), so as flex items of the
     stage they shrank to what the floored middle grid left and clipped their
     own small grids. Keep their heights; the page scrolls. */
  @media (max-width: 639px), (max-height: 500px) and (pointer: coarse) {
    .pin-strip { flex-shrink: 0; }
  }
</style>

View this example on GitHub

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.