Column pinning + freezing
Wide 13-column grid. Pin Company left and Price right via the column menu; the middle scrolls under sticky edges.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Columns). See the SvGrid documentation for the full API.
About this example
Column pinning in the Svelte 5 data grid. A wide eight-column grid pins Company to the left and Unit price to the right through initialColumnPinning, so they stay sticky while the middle columns scroll under them; the header menu offers Pin to left, Pin to right and Unpin, and api.setColumnPinning does the same from code. Pinning needs columnVirtualization={false}, and the menu hides the pin items automatically when it is on.
Wide grid (8 generous columns ~2050px total) so horizontal scrolling kicks in on any reasonable viewport. Company is pre-pinned to the LEFT and Unit price to the RIGHT - they stay sticky while the middle columns slide under them when you scroll.
Try it:
- Scroll the grid horizontally - Company stays anchored on the left, Unit price on the right, the rest scroll between them.
- Hover any middle-column header → click the ⋮ that appears → "Pin to left" / "Pin to right" / "Unpin column".
Note: column pinning requires columnVirtualization={false} because the virtualizer recycles DOM nodes (which breaks sticky positioning). The pin menu items are hidden when virtualization is on, so the gating is automatic.
A programmatic api.setColumnPinning(id, side) is on the v1.x roadmap; today the initialColumnPinning prop covers the on-mount case and the column menu covers user-driven re-pinning.
Imports, features and API used
Imports: @svgrid/grid, ../shared/seed
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: company (Company), product (Product), orderId (Order ID), country (Country), sellDate (Sell date), quantity (Quantity), inStock (In stock), price (Unit price)
SvGridApi methods called: api.setColumnPinning()
Frequently asked questions
How do I pin a column on mount?
Pass initialColumnPinning={{ left: ['company'], right: ['price'] }} to <SvGrid>. Users can change it later from each header's menu, and api.setColumnPinning({ left, right }) sets it from code.
Why must column virtualization be off?
The column virtualizer recycles DOM nodes, which breaks position: sticky. Set columnVirtualization={false} on grids that pin; the pin menu items are hidden while it is on so the two cannot conflict.
Can pinned columns be resized?
Yes. columnResize is on in this demo and works on pinned columns like any other; the pinned edge keeps its position while the column grows.
Related documentation
Related articles
- Pinned (Frozen) Columns for Wide Svelte Data Grids - How to pin identity and action columns in SvGrid so users never lose their place while scrolling wide tables - declarative config, runtime API, and the tradeoffs that matter.
Source code (25-column-pinning.svelte)
<!-- Documented in: docs/help/recipes.md -->
<script lang="ts">
/**
* 25. Column pinning + freezing
* -----------------------------
* Wide grid (8 generous columns ~2050px total) so horizontal scrolling
* kicks in on any reasonable viewport. Company is pre-pinned to the
* LEFT and Unit price to the RIGHT - they stay sticky while the middle
* columns slide under them when you scroll.
*
* Try it:
* - Scroll the grid horizontally - Company stays anchored on the left,
* Unit price on the right, the rest scroll between them.
* - Hover any middle-column header → click the ⋮ that appears →
* "Pin to left" / "Pin to right" / "Unpin column".
*
* Note: column pinning requires `columnVirtualization={false}` because
* the virtualizer recycles DOM nodes (which breaks sticky positioning).
* The pin menu items are hidden when virtualization is on, so the
* gating is automatic.
*
* A programmatic `api.setColumnPinning(id, side)` is on the v1.x
* roadmap; today the `initialColumnPinning` prop covers the on-mount
* case and the column menu covers user-driven re-pinning.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
} from '@svgrid/grid'
import { makeOrders, type Order } from '../shared/seed'
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
})
let rows = $state<Order[]>(makeOrders(500))
// 8 generous columns - total ~2050px so the grid overflows horizontally
// on any normal monitor. Pinning is only meaningful when the grid
// actually scrolls; the demo has to force that condition to demonstrate
// the feature.
const columns: GridColumns<Order> = [
{ field: 'company', header: 'Company', width: 240 },
{ field: 'product', header: 'Product', width: 320 },
{ field: 'orderId', header: 'Order ID', width: 180 },
{ field: 'country', header: 'Country', width: 200 },
{ field: 'sellDate', header: 'Sell date', width: 220,
format: { type: 'date', pattern: 'y-m-d' } },
{ field: 'quantity', header: 'Quantity', width: 170,
format: { type: 'number', options: { maximumFractionDigits: 0 } } },
{ field: 'inStock', header: 'In stock', width: 160 },
{ field: 'price', header: 'Unit price', width: 200,
format: { type: 'currency', currency: 'USD' } },
]
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div
class="shrink-0 rounded-lg border px-4 py-3"
style="border-color: var(--sg-border); background: var(--sg-header-bg);"
>
<p class="text-sm font-semibold" style="color: var(--sg-fg);">
Company is pinned LEFT · Unit price is pinned RIGHT
</p>
<ol class="mt-1 text-xs list-decimal pl-5 space-y-0.5" style="color: var(--sg-fg);">
<li>
<strong>Scroll horizontally</strong> - Company and Unit price stay anchored at the edges
while everything else slides between them.
</li>
<li>
<strong>Pin another column</strong> - hover any middle header → click the
<code style="color: var(--site-accent-2, #22d3ee);">⋮</code> → pick
<em>Pin to left</em>, <em>Pin to right</em>, or <em>Unpin column</em>.
</li>
</ol>
</div>
<div class="flex-1 min-h-0 pinning-host">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="menu"
showRowNumbers={true}
showPagination={true}
pageSize={50}
enableCellSelection={true}
rowHeight={36}
containerHeight="100%"
fitColumns={false}
columnVirtualization={false}
initialColumnPinning={{ left: ['company'], right: ['price'] }}
/>
</div>
<footer class="pin-foot text-xs shrink-0">
{rows.length} rows · {columns.length} columns ·
<code>fitColumns={false}</code> +
<code>columnVirtualization={false}</code>
so the grid keeps its declared widths and the sticky pinned columns work.
</footer>
</section>
<style>
.pin-foot { color: var(--sg-muted, #64748b); }
/* Punch up the visual contrast for pinned columns so the user can
immediately see which columns are frozen. The grid already supports
these tokens; this demo just turns the dial higher to demo the
feature. */
.pinning-host {
/* Body cells: a stronger accent-tinted background than the default
8% mix - the pinned strip should read as a clearly different surface.
The two color-mix percentages MUST sum to 100% - otherwise CSS scales
the result's alpha down (e.g. 55% + 18% = 73% -> 0.73 alpha), which
makes the pinned column semi-transparent and lets the scrolling
middle columns bleed through. */
--sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #ffffff) 82%, var(--sg-accent, #2563eb) 18%);
/* Header cells: even bolder so the frozen header is unmistakable. */
--sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 70%, var(--sg-accent, #2563eb) 30%);
/* Divider line on the inside edge - solid accent so it reads as
intentional, not as a normal cell border. */
--sg-pinned-divider: var(--sg-accent, #2563eb);
}
:global(html[data-theme='dark']) .pinning-host {
--sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #181d27) 76%, var(--sg-accent, #3b82f6) 24%);
--sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 64%, var(--sg-accent, #3b82f6) 36%);
--sg-pinned-divider: var(--sg-accent, #3b82f6);
}
/* No icon - the accent-tinted background tokens above (--sg-pinned-bg
/ --sg-pinned-header-bg) carry the "this column is frozen" signal
on their own. Punch the header tint further to make it unmistakable
even at a glance. */
.pinning-host {
--sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 58%, var(--sg-accent, #2563eb) 42%);
}
:global(html[data-theme='dark']) .pinning-host {
--sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 52%, var(--sg-accent, #3b82f6) 48%);
}
/* Make the pinned header text white on the now-strong accent fill so
contrast stays readable in both modes. */
.pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned]) {
color: #fff;
}
.pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned] *) {
color: inherit;
}
</style>More Columns examples
- 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.
- Column reorder - Set enableColumnReorder on <SvGrid> and every header becomes draggable, with a drop indicator. api.setColumnOrder / getColumnOrder + onColumnOrderChange event; persist to restore across reloads.
- Autosize columns - api.autosizeColumn(id) and api.autosizeAllColumns() snap columns to the widest visible cell via canvas-based text measurement. The column header menu has an "Autosize" item that calls the same code. Manual drag-resize still works.