Column Pinning Table
Freeze columns to the left or right edge — Excel/AG Grid style — while the rest of the table scrolls underneath. Hover a header to pin it left or right; hover a pinned header to unpin it.
npx shadcn add https://shad-table.dev/r/column-pinning-table.jsonName | Email | Department | Role | Location | Manager | Start Date | Salary | Status |
|---|---|---|---|---|---|---|---|---|
| Ava Thompson | ava.t@acme.dev | Engineering | Staff Engineer | Remote — US | Grace Coleman | 2019-03-11 | $182,000 | Active |
| Liam Chen | liam.chen@acme.dev | Design | Product Designer | New York, NY | Sofia Reyes | 2021-07-02 | $134,000 | Active |
| Sofia Patel | sofia.p@acme.dev | Product | PM II | Remote — EU | Noah Park | 2020-01-20 | $151,000 | On leave |
| Noah Garcia | noah.g@acme.dev | Engineering | Eng Manager | Austin, TX | Ava Thompson | 2018-09-14 | $196,000 | Active |
| Mia Johnson | mia.j@acme.dev | Support | Support Lead | Remote — US | Ethan Kim | 2022-02-08 | $98,000 | Active |
| Ethan Kim | ethan.kim@acme.dev | Support | Director, Support | Seattle, WA | Grace Coleman | 2017-05-30 | $168,000 | Inactive |
| Isabella Rossi | isabella.r@acme.dev | Design | Design Systems Lead | Remote — EU | Liam Chen | 2020-11-16 | $145,000 | Active |
| Lucas Martin | lucas.m@acme.dev | Sales | Account Executive | Chicago, IL | Charlotte Diaz | 2021-04-05 | $112,000 | Active |
| Amelia Novak | amelia.n@acme.dev | Engineering | Senior Engineer | Remote — US | Noah Garcia | 2019-12-01 | $165,000 | Active |
| Mason Lee | mason.lee@acme.dev | Product | Senior PM | San Francisco, CA | Sofia Patel | 2018-06-18 | $174,000 | On leave |
How it works
1.columnPinning is just more TanStack Table state
Pinning isn't a separate system bolted onto the table — it's a ColumnPinningState of { left: string[], right: string[] } wired through state/onColumnPinningChange exactly like sorting or filtering. column.pin('left'), column.pin('right'), and column.pin(false) mutate that state for you.
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(initialPinning ?? {},)const table = useReactTable({data,columns,state: { columnPinning },onColumnPinningChange: setColumnPinning,})
2.Pinned cells become sticky, offset by the other pinned columns
getStart('left') sums the widths of every column pinned left before this one, and getAfter('right') does the same from the right edge — so a second left-pinned column sticks right next to the first instead of overlapping it. Unpinned columns stay position: relative and scroll normally.
function getPinningStyles(column) {const isPinned = column.getIsPinned()return {left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,position: isPinned ? 'sticky' : 'relative',zIndex: isPinned ? 1 : 0,}}
3.The boundary shadow only appears on the outermost pinned cell
getIsLastColumn('left') and getIsFirstColumn('right') identify the cell sitting right at the scroll boundary — that's the only place the drop-shadow divider is drawn, which is what makes a block of pinned columns read as one solid panel instead of every cell getting its own shadow.
const isLastLeftPinned =isPinned === 'left' && column.getIsLastColumn('left')const isFirstRightPinned =isPinned === 'right' && column.getIsFirstColumn('right')className={cn(isLastLeftPinned && 'shadow-[2px_0_4px_-2px_rgba(0,0,0,0.15)]',isFirstRightPinned && 'shadow-[-2px_0_4px_-2px_rgba(0,0,0,0.15)]',)}
4.Sticky cells need an opaque background of their own
A sticky cell sits above the rows scrolling underneath it, so without bg-background it would show the row content bleeding through. Every pinned header and cell gets that class regardless of pin side.
<TableHead className={cn('group/head bg-background', ...)}><TableCell className={cn('bg-background', ...)}>
5.Pin controls live in the header and default to hidden
Each header renders pin-left / pin-right buttons (or a single unpin button once pinned) inside a group that's opacity-0 by default and opacity-100 on header hover — the same interaction AG Grid and Excel use, without needing a context menu.
<ButtonclassName="opacity-0 group-hover/head:opacity-100"onClick={() => column.pin('left')}><ArrowLeftToLine /></Button>