Column Pinning
Freeze a column at either edge and it stays put while everything between it scrolls. Declare it on the column for the layout you ship, or change it at runtime from a saved snapshot — the same property, written two ways.
Declared on the column — two frozen at each edge
Two columns pinned left, two pinned right, eight in between. Scroll the grid sideways: the four frozen ones never move, they stack in array order, and only the outermost of each pair carries the seam.
pin: 'left' and pin: 'right' are the whole API. Four columns declare
one here, and the eight between them scroll:
{ key: 'id', name: '#', width: 64, pin: 'left' },
{ key: 'firstName', name: 'Name', width: 150, pin: 'left' },
…
{ key: 'status', name: 'Status', width: 110, pin: 'right' },
{ key: 'actions', name: 'Actions', width: 100, pin: 'right' },
They stack in array order, running inward from the edge: # sits at
the very left because it comes first, with Name beside it; on the other side
Actions sits at the very right because it comes last. Reorder the array and the
frozen pair reorders with it.
Only the column that touches the scrolling area carries the seam. On the left
that is Name, not # — the boundary between the two of them is an
ordinary column separator, because it is not where frozen meets scrolling. The seam marks that
one edge and nothing else, and scrolling sideways leaves it exactly as strong at every position,
including the very end of the travel.
The selection checkbox is not one of your columns and it is frozen too. It is
prepended before every pinned-left column and its sticky offset is worked out for you, so
combining rowSelection or rowExpand with pinning needs no offset
arithmetic on your side.
Changed at runtime — setState() with a column snapshot
Email starts unpinned. Move it to either edge and back without rebuilding the columns array — read the current snapshot, change one entry's pin, hand the whole list back. The last button hands back a one-entry list instead, which is the mistake worth seeing once.
# (left)#, Name, Surname, Role, Department, Office, Email, Salary, Start date, Status
Nothing on this grid's columns array declares a pin except #. The first
three buttons move Email by writing a state snapshot back:
setPin(key: string, pin: ColumnPin | undefined) {
const columns = (grid.getState().columns ?? [])
.map(c => (c.key === key ? { ...c, pin } : c));
grid.setState({ columns });
}
Watch the two readouts under the grid: Frozen now and Column order
are both read back out of getState() after each press, so what you see is the grid's
own account of itself rather than this page's.
Hand back the whole list, always
A column snapshot restores the order from each entry's position in the array —
not from its order field, which is recorded for you but not read back. The fourth
button is the proof, and it is telling the truth when it does it:
grid.setState({ columns: [{ key: 'email', order: 6, pin: 'left' }] });
Email really is the seventh column, so order: 6 is correct. Press the button
and Email still arrives at the front of the order, ahead of #,
because it is the only entry in the array and the nine columns the snapshot never mentions have
no position left to keep. Read the Column order line;
Reset layout puts it back.
Reading the current state and mapping over it is what keeps the other nine columns where they are.
It is also why this beats rebuilding your columns array: resize a column, then pin
Email, and the width survives — a width the reader dragged lives in the grid's state, not in the
array your component holds, so handing the grid a freshly built array would put it back to its
declared value.
ColumnPin is 'left' | 'right', and undefined is the third
value — a snapshot entry carrying pin: undefined clears an existing pin, which is
what Unpin sends.
What pinning is
A pinned column is position: sticky at one edge of the scrollport: it keeps its
place in the row while the columns between the two frozen groups scroll under it. It is a layout
property, not a data one — sorting, filtering, selection and editing all behave exactly as they
did.
pin: 'left'— the start edge. Identity columns live here, so a reader scrolled far to the right still knows which row they are on.pin: 'right'— the end edge. Actions live here, so the button is reachable without scrolling back.- Order within a side is array order, running outward from the scrolling middle.
'left' and 'right' are logical sides. Under
config.dir: 'rtl' a left-pinned column freezes at the right-hand edge, so one array
serves both directions — see RTL Support, where the same pinned
pair is shown in each.
The seam
A frozen group is separated from the scrolling area by a single crisp 1px line, and no drop shadow. A frozen column is on the same plane as the rest of the grid — it simply does not move — so it reads as a divider rather than as a raised surface. The line is a step stronger than an ordinary column separator, looks identical at every scroll position, and is carried by the one frozen column that touches the scrolling area — never by the ones behind it.
Its colour is --gc-pin-edge. Set it to transparent to remove the line,
or set --gc-pin-shadow-left / --gc-pin-shadow-right if you would rather
have a shadow — both still work, they are simply switched off by default.
Two ways to set it, and when each is right
- On the column — the layout you ship. Static, declarative, and what a reader copying the first example gets.
- Through a state snapshot —
[initialState]for an opening layout the reader saved earlier, orsetState()for a change while the grid is on screen. Both take the samecolumnsarray of{ key, width, order, hidden, pin }, and both are covered on Grid State.
persistColumns stores that same snapshot for you, so a pin the reader set at runtime
is still there after a reload with no code of yours in between.
What it costs
Nothing per row: a pinned column is the same cell with two extra CSS properties, so a grid with four frozen columns renders exactly as many nodes as one with none. The one thing worth watching is width — freeze more than the scrollport can spare and the scrolling middle has nowhere left to go. Pin what a reader needs to keep their place, and leave the rest.
Selection-checkbox and expand-toggle columns are prepended before every pinned-left column,
with their sticky offsets and stacking handled for you — no offset arithmetic when combining
pinning with rowSelection or rowExpand.
Pinning options
| Property | Type | Default | Description |
|---|---|---|---|
| column.pin | 'left' | 'right' | unpinned | Freezes the column at that edge. Columns pinned to the same side stack in array order, outward from the scrolling middle. Logical sides — they mirror under dir: 'rtl'. |
| column.cellTemplate | TemplateRef<CellContext<T>> | — | Custom cell content — the usual pattern for a pinned-right actions column. See Cell Templates. |
| getState().columns | ColumnState[] | — | A snapshot entry per column — { key, width, order, hidden, pin }. On restore the column order comes from each entry's POSITION in the array, not from its order field. |
| setState({ columns }) | (s: Partial<GridStateSnapshot>) => void | — | Applies a column snapshot to a live grid. Pass the whole list: a partial one reorders. pin: undefined clears an existing pin. |
| [initialState] | GridStateSnapshot | — | The same snapshot, applied before the first fetch — for restoring a layout the reader saved. See Grid State. |
| config.persistColumns | ColumnPersistConfig | — | Stores the column snapshot (widths, order, hidden and pins) automatically, so a runtime pin survives a reload. |
| --gc-pin-edge | CSS custom property | theme | Colour of the 1px line between a frozen group and the scrolling area. Carried by the frozen column that touches the scrolling area, never by the ones behind it; set to transparent to remove it. |
| --gc-pin-shadow-left / --gc-pin-shadow-right | CSS custom property | transparent | Optional drop shadow under a frozen group. Off by default — the 1px line is the cue. Set either one to add a shadow back. |