Home/Columns/Column Pinning
Columns

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.

#
Name
Surname
Role
Department
Office
Email
Salary
Start date
Status
Actions
1
Nadia
Haddad
Staff Engineer
Engineering
Amman
nadia.haddad@example.com
68000
2014-01-10
On leave
2
Ben
Okafor
Product Designer
Design
Lagos
ben.okafor@example.com
72700
2015-02-11
Active
3
Yara
Nasser
Data Engineer
Data
Dubai
yara.nasser@example.com
77400
2016-03-12
Active
4
Karim
Farouk
Support Specialist
Support
Cairo
karim.farouk@example.com
82100
2017-04-13
Active
5
Elena
Novak
Security Analyst
Security
Prague
elena.novak@example.com
86800
2018-05-14
Active
6
Walid
Basha
Finance Controller
Finance
Riyadh
walid.basha@example.com
91500
2019-06-15
On leave
7
Alice
Smith
Recruiter
People
Berlin
alice.smith@example.com
96200
2020-07-16
Active
8
Petra
Lange
Technical Writer
Product
Oslo
petra.lange@example.com
100900
2021-08-17
Active
9
Idris
Salem
Staff Engineer
Engineering
Amman
idris.salem@example.com
105600
2022-09-18
Active
10
Tara
Aziz
Product Designer
Design
Lagos
tara.aziz@example.com
110300
2023-01-19
Active
1–10 of 24

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.

Email column:
#
Name
Surname
Role
Department
Office
Email
Salary
Start date
Status
1
Nadia
Haddad
Staff Engineer
Engineering
Amman
nadia.haddad@example.com
68000
2014-01-10
On leave
2
Ben
Okafor
Product Designer
Design
Lagos
ben.okafor@example.com
72700
2015-02-11
Active
3
Yara
Nasser
Data Engineer
Data
Dubai
yara.nasser@example.com
77400
2016-03-12
Active
4
Karim
Farouk
Support Specialist
Support
Cairo
karim.farouk@example.com
82100
2017-04-13
Active
5
Elena
Novak
Security Analyst
Security
Prague
elena.novak@example.com
86800
2018-05-14
Active
6
Walid
Basha
Finance Controller
Finance
Riyadh
walid.basha@example.com
91500
2019-06-15
On leave
7
Alice
Smith
Recruiter
People
Berlin
alice.smith@example.com
96200
2020-07-16
Active
8
Petra
Lange
Technical Writer
Product
Oslo
petra.lange@example.com
100900
2021-08-17
Active
9
Idris
Salem
Staff Engineer
Engineering
Amman
idris.salem@example.com
105600
2022-09-18
Active
10
Tara
Aziz
Product Designer
Design
Lagos
tara.aziz@example.com
110300
2023-01-19
Active
1–10 of 24
Frozen now:# (left)
Column order:#, 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, or setState() for a change while the grid is on screen. Both take the same columns array 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

PropertyTypeDefaultDescription
column.pin'left' | 'right'unpinnedFreezes 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.cellTemplateTemplateRef<CellContext<T>>—Custom cell content — the usual pattern for a pinned-right actions column. See Cell Templates.
getState().columnsColumnState[]—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.persistColumnsColumnPersistConfig—Stores the column snapshot (widths, order, hidden and pins) automatically, so a runtime pin survives a reload.
--gc-pin-edgeCSS custom propertythemeColour 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-rightCSS custom propertytransparentOptional drop shadow under a frozen group. Off by default — the 1px line is the cue. Set either one to add a shadow back.