Freeze panes
The Excel Freeze Panes corner on a plain <SvGrid>: the Account and Owner columns stay pinned while you scroll across a full year of months, and the sticky column-letter + row-number headers stay put as you scroll down. HyperFormula keeps each row total (column O) and the bottom Total row live as you edit any month. Pinning those two columns is one prop: initialColumnPinning.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Columns). See the SvGrid documentation for the full API.
About this example
Excel freeze panes on a plain Svelte 5 SvGrid. The Account and Owner columns stay pinned with initialColumnPinning while you scroll across a full year of month columns, the row-number gutter is sticky by construction, and the sticky header keeps the column letters in view while you scroll down. HyperFormula keeps each row's total column and the bottom Total row live as you edit any month.
A plain <SvGrid responsive={true}> as an Excel freeze-panes sheet. The row-number gutter is sticky by construction; pinning the first two letter columns (initialColumnPinning) keeps Account + Owner in view while you scroll across a full year of months, and the sticky header keeps A..O in view while you scroll down. HyperFormula keeps the row totals (O) and the Total row live.
Imports, features and API used
Imports: svelte, hyperformula, @svgrid/grid
Frequently asked questions
How do I freeze the first columns?
Pass initialColumnPinning={{ left: ['A', 'B'] }} with the ids of the columns to keep in view. The pinned cells are sticky, so they stay while the rest scrolls.
Is the header frozen automatically?
Yes. The header row is sticky in every SvGrid, and the row-number gutter is too, so the freeze-panes corner needs no extra work.
How do the totals stay live?
Every row's total cell holds a =SUM formula over its months and the Total row sums each column; HyperFormula recomputes them on every commit.
Related documentation
Related articles
- Multi-Level (Grouped) Column Headers in SvGrid - Band related columns under a shared parent header using SvGrid's nested column definition - how to nest, pin, combine with sorting and filtering, and when NOT to use grouping.
- A Custom Column Header Menu in SvGrid - Build a per-column header menu for sort, hide, pin, and custom actions using header snippets and your own dropdown component.
- A Column Show/Hide Toggle in SvGrid - Build a column chooser panel that lets users show or hide columns at runtime, with persistence across page loads and a guard against hiding everything.
Source code (208-freeze-panes.svelte)
<script lang="ts">
/**
* 208. Freeze panes
* ------------------
* A plain <SvGrid responsive={true}> as an Excel freeze-panes sheet. The row-number gutter is
* sticky by construction; pinning the first two letter columns
* (initialColumnPinning) keeps Account + Owner in view while you scroll across
* a full year of months, and the sticky header keeps A..O in view while you
* scroll down. HyperFormula keeps the row totals (O) and the Total row live.
*/
import { onDestroy } from 'svelte'
import { HyperFormula } from 'hyperformula'
import {
SvGrid,
tableFeatures,
renderSnippet,
rowResize,
createHyperFormulaSheet,
type GridColumns,
type HyperFormulaInstance,
type HyperFormulaSheet,
} from '@svgrid/grid'
function colLetters(n: number): string[] {
return Array.from({ length: n }, (_, i) => String.fromCharCode(65 + i))
}
const letters = colLetters(26) // A=Account, B=Owner, C..N=Jan..Dec, O=Total, P..Z empty
const MIN_ROWS = 80
const monthCols = letters.slice(2, 14) // C..N
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
type Row = Record<string, unknown>
const ACCOUNTS = ['Salaries','Contractors','Cloud & hosting','SaaS tools','Office rent','Utilities','Travel','Marketing','Advertising','Events','Legal','Accounting','Insurance','Hardware','Software licenses','Recruiting','Training','Support','R&D','Misc']
const OWNERS = ['People','Eng','Eng','Eng','Ops','Ops','Ops','Growth','Growth','Growth','Finance','Finance','Finance','Eng','Eng','People','People','Support','Eng','Ops']
function seededAmount(a: number, m: number): number {
const x = Math.sin((a + 1) * 12.9898 + (m + 1) * 78.233) * 43758.5453
return Math.round(((800 + a * 140) * (0.6 + (x - Math.floor(x)) * 0.9)) / 10) * 10
}
const HEADER = 1
const FIRST = 2
const lastData = FIRST + ACCOUNTS.length - 1
const totalRow = lastData + 1
const empty = (): Row => Object.fromEntries(letters.map((c) => [c, ''])) as Row
const seed: Row[] = Array.from({ length: Math.max(totalRow, MIN_ROWS) }, empty)
const put = (row: number, col: string, v: unknown) => { seed[row - 1]![col] = v }
put(HEADER, 'A', 'Account'); put(HEADER, 'B', 'Owner')
MONTHS.forEach((mn, i) => put(HEADER, monthCols[i]!, mn))
put(HEADER, 'O', 'Total')
ACCOUNTS.forEach((name, a) => {
const row = FIRST + a
put(row, 'A', name); put(row, 'B', OWNERS[a]!)
monthCols.forEach((c, m) => put(row, c, seededAmount(a, m)))
put(row, 'O', `=SUM(${monthCols[0]}${row}:${monthCols[11]}${row})`)
})
put(totalRow, 'A', 'Total')
;[...monthCols, 'O'].forEach((c) => put(totalRow, c, `=SUM(${c}${FIRST}:${c}${lastData})`))
const hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' })
hf.addSheet('Sheet1')
const sheet: HyperFormulaSheet<Row> = createHyperFormulaSheet({
hyperformula: hf as unknown as HyperFormulaInstance,
rows: seed.map((r) => ({ ...r })),
fields: letters as unknown as ReadonlyArray<keyof Row & string>,
})
let raw = $state<Row[]>(sheet.raw)
let computed = $state<Row[]>(sheet.computed)
const features = tableFeatures({})
function commitCell(rowIndex: number, columnId: string, value: unknown) {
let next: unknown = value
if (typeof next === 'string' && next.trim() !== '' && !next.trim().startsWith('=')) {
const n = Number(next)
if (Number.isFinite(n)) next = n
}
const snap = sheet.update(rowIndex, columnId, next)
computed = snap.computed
raw = snap.raw
}
onDestroy(() => sheet.destroy())
function fmt(v: unknown): string {
if (v == null || v === '') return ''
if (typeof v === 'number') return Number.isInteger(v) ? v.toLocaleString('en-US') : v.toLocaleString('en-US', { maximumFractionDigits: 2 })
return String(v)
}
const columns: GridColumns<Row> = letters.map((letter) => ({
field: letter,
header: letter,
width: letter === 'A' ? 150 : letter === 'B' ? 96 : 88,
align: letter === 'A' || letter === 'B' ? 'left' : 'right',
editorType: 'text',
cellClass: (ctx: { row: { index: number } }) => {
const rn = ctx.row.index + 1
if (rn === HEADER) return 'sheet-head'
if (rn === totalRow) return 'sheet-total'
if (letter === 'B') return 'sheet-label' // A is the frozen header column (global style)
return ''
},
cell: (ctx: { row: { index: number } }) => renderSnippet(Cell, { value: computed[ctx.row.index]?.[letter] }),
}))
let heights = $state<Record<number, number>>({})
const rowHeight = (i: number) => heights[i] ?? 24
const onRowResize = (i: number, h: number) => (heights = { ...heights, [i]: h })
</script>
{#snippet Cell({ value }: { value: unknown })}
<span class="cellv">{fmt(value)}</span>
{/snippet}
<section class="sheet-demo">
<header class="bs-head">
<h2 class="bs-title">Freeze panes</h2>
<p class="bs-sub">
Scroll <strong>right</strong> across the twelve months - <em>Account</em> and
<em>Owner</em> stay frozen (pinned columns) next to the sticky row-number gutter.
Scroll <strong>down</strong> - the header stays frozen on top. Edit any month and the
row total (O) and bottom Total row recompute.
</p>
</header>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="sheet" use:rowResize={{ onResize: onRowResize, min: 20, max: 320 }}>
<SvGrid responsive={true}
columnResize
data={raw}
columns={columns}
features={features}
sortable={false}
filterable={false}
selectionMode="cell"
showRowNumbers={true}
rowNumberWidth={46}
initialColumnPinning={{ left: ['A', 'B'] }}
columnVirtualization={false}
showColumnFilters={false}
showRowSelection={false}
enableInlineEditing={true}
enableRowHover={false}
enableCellSelection={true}
contextMenu={['copy', 'cut', 'paste', 'clear']}
rowHeight={rowHeight}
containerHeight="100%"
fitColumns={false}
onCellValueChange={(e) => { if (e.columnId !== '__rownum__') commitCell(e.rowIndex, e.columnId, e.newValue) }}
/>
</div>
</section>
<!-- Shared Excel chrome is `.sheet-demo` in src/index.css; only the
freeze-panes-specific tints live here. -->
<style>
.sheet :global(.sv-grid-cell.sheet-total) { background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, var(--sg-bg, #fff) 88%) !important; font-weight: 700; }
/* Opaque (mix of two solid colors, no `transparent`) - it's a pinned column,
so a see-through background would let scrolled content bleed through. */
.sheet :global(.sv-grid-cell.sheet-label) { text-align: left; font-weight: 600; background: color-mix(in srgb, var(--sg-header-bg, #f1f5f9) 65%, var(--sg-bg, #fff) 35%) !important; }
</style>More Columns examples
- Column pinning + freezing - Wide 13-column grid. Pin Company left and Price right via the column menu; the middle scrolls under sticky edges.
- Drag to pin columns - Drag the line at the edge of the pinned columns to pin more or fewer: pinSplitter snaps to column edges, shows how many it will pin, keeps the columns where they are on screen and leaves room for the ones that scroll. Escape cancels; Tab to it and use the arrow keys. Free, and loaded only when the prop is on.
- Columns hierarchy + manager - Side-panel tree of grouped columns: drag leaves to reorder, click a chevron to collapse a group into one summary column, toggle visibility per leaf or whole group.
- Column layout API - setColumnWidth + setColumnPinning + getColumnWidths + getColumnPinning. Save the snapshot to localStorage, restore on reload, drive widths and pins from buttons.
- Tool panel (Columns + Filters) - The docked enterprise sidebar, two tabs. Columns: toggle visibility, reorder up/down, group by a column. Filters: an operator + value control per column (numeric operators come free via cellDataType), kept in sync with the column menu. Enable with the toolPanel prop.