Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

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.json
Name
Email
Department
Role
Location
Manager
Start Date
Salary
Status
Ava Thompsonava.t@acme.devEngineeringStaff EngineerRemote — USGrace Coleman2019-03-11$182,000Active
Liam Chenliam.chen@acme.devDesignProduct DesignerNew York, NYSofia Reyes2021-07-02$134,000Active
Sofia Patelsofia.p@acme.devProductPM IIRemote — EUNoah Park2020-01-20$151,000On leave
Noah Garcianoah.g@acme.devEngineeringEng ManagerAustin, TXAva Thompson2018-09-14$196,000Active
Mia Johnsonmia.j@acme.devSupportSupport LeadRemote — USEthan Kim2022-02-08$98,000Active
Ethan Kimethan.kim@acme.devSupportDirector, SupportSeattle, WAGrace Coleman2017-05-30$168,000Inactive
Isabella Rossiisabella.r@acme.devDesignDesign Systems LeadRemote — EULiam Chen2020-11-16$145,000Active
Lucas Martinlucas.m@acme.devSalesAccount ExecutiveChicago, ILCharlotte Diaz2021-04-05$112,000Active
Amelia Novakamelia.n@acme.devEngineeringSenior EngineerRemote — USNoah Garcia2019-12-01$165,000Active
Mason Leemason.lee@acme.devProductSenior PMSan Francisco, CASofia Patel2018-06-18$174,000On 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.

src/components/column-pinning/data-table.tsx
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.

src/components/column-pinning/data-table.tsx
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.

src/components/column-pinning/data-table.tsx
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.

src/components/column-pinning/data-table.tsx
<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.

src/components/column-pinning/data-table.tsx
<Button
className="opacity-0 group-hover/head:opacity-100"
onClick={() => column.pin('left')}
>
<ArrowLeftToLine />
</Button>