Row pinning
Pin a row and it stays at the top or the bottom of the grid while the others scroll, and stays
there through sorting, filtering and paging. There is no built-in button for it: you call row.pin
from your own row-actions menu.
| Idris Khan | Churned | Sales | $0.00 | Jul 13, 2026 | |
| Zara Nguyen | Paused | Sales | $0.00 | Aug 6, 2024 | |
| Olivia Tanaka | Active | Research | $638.00 | Nov 30, 2025 | |
| Kavya Tanaka | Active | Design | $0.00 | Aug 1, 2025 | |
| Olivia Prabhakaran | Active | Research | $4,266.00 | Sep 25, 2026 | |
| Mia Das | Active | Support | $36.00 | Apr 9, 2024 | |
| Liam Nguyen | Invited | Support | $0.00 | Apr 3, 2024 | |
| Omar Das | Invited | Finance | $36.00 | Jun 28, 2024 | |
| Omar Khan | Active | Support | $0.00 | Apr 4, 2025 | |
| Aarav Prabhakaran | Active | Data | $0.00 | Apr 12, 2025 | |
| Yuki Tanaka | Paused | Sales | $0.00 | May 2, 2023 | |
| Diya Prabhakaran | Active | Growth | $928.00 | Aug 19, 2023 | |
| Arjun Menon | Active | Growth | $1,421.00 | Jan 18, 2024 | |
| Olivia Müller | Active | Design | $4,977.00 | May 16, 2024 | |
| Freya Garcia | Active | Platform | $48.00 | Oct 13, 2023 | |
| Noah Müller | Paused | Sales | $203.00 | Nov 25, 2025 | |
| Elias Okafor | Active | Platform | $0.00 | Feb 21, 2025 | |
| Kavya Silva | Invited | Design | $1,738.00 | Nov 8, 2023 | |
| Rohan Rossi | Active | Sales | $12.00 | Oct 10, 2025 | |
| Noah Prabhakaran | Active | Growth | $3,792.00 | Jul 6, 2024 | |
| Amelia Das | Churned | Growth | $0.00 | Jan 2, 2023 | |
| Haruto Müller | Active | Support | $36.00 | Nov 1, 2024 | |
| Zara Nair | Active | Platform | $667.00 | Sep 12, 2025 | |
| Diya Smith | Invited | Sales | $24.00 | Jun 21, 2026 | |
| Sofia Reddy | Paused | Design | $0.00 | Sep 16, 2024 | |
| Ananya Nguyen | Active | Data | $24.00 | Jan 10, 2023 | |
| Diya Kim | Invited | Research | $48.00 | Nov 4, 2023 | |
| Mateo Dubois | Active | Sales | $24.00 | Oct 1, 2026 | |
| Olivia Iyer | Active | Data | $12.00 | Sep 20, 2024 | |
| Vihaan Iyer | Invited | Support | $1,624.00 | Jan 14, 2025 | |
| Lucas Garcia | Active | Finance | $261.00 | May 12, 2025 | |
| Kwame Das | Invited | Design | $48.00 | May 25, 2026 | |
| Meera Silva | Active | Platform | $36.00 | Jul 26, 2026 | |
| Haruto Smith | Active | Sales | $48.00 | Mar 19, 2023 | |
| Leila Prabhakaran | Invited | Platform | $3,950.00 | Jan 21, 2025 | |
| Theo Garcia | Paused | Finance | $1,501.00 | Feb 28, 2026 | |
| Nia Bose | Churned | Platform | $0.00 | Oct 31, 2024 | |
| Diya Kim | Active | Design | $12.00 | Oct 4, 2026 | |
| Theo Nguyen | Active | Data | $1,422.00 | Jul 18, 2025 | |
| Vihaan Rossi | Invited | Finance | $0.00 | Mar 8, 2024 |
import { ArrowDownToLineIcon, ArrowUpToLineIcon, MoreHorizontalIcon, PinOffIcon,} from "lucide-react";
import { Button } from "@/components/ui/button";import { DataGrid, createDataGridColumns } from "@/components/ui/data-grid";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu";
import { makePeople, STATUS_OPTIONS, type Person } from "@/lib/data-grid-demo-data";
const col = createDataGridColumns<Person>();
const columns = col.columns([ col.text("name", { header: "Name" }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.text("team", { header: "Team" }), col.currency("mrr", { header: "MRR", currency: "USD" }), col.date("joinedAt", { header: "Joined" }), col.actions(({ row }) => { const pinned = row.getIsPinned(); return ( <DropdownMenu modal={false}> <DropdownMenuTrigger render={ <Button variant="ghost" size="icon-xs" aria-label={`Actions for ${row.original.name}`} /> } > <MoreHorizontalIcon /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {pinned !== "top" ? ( <DropdownMenuItem onClick={() => row.pin("top")}> <ArrowUpToLineIcon /> Pin row to top </DropdownMenuItem> ) : null} {pinned !== "bottom" ? ( <DropdownMenuItem onClick={() => row.pin("bottom")}> <ArrowDownToLineIcon /> Pin row to bottom </DropdownMenuItem> ) : null} {pinned ? ( <DropdownMenuItem onClick={() => row.pin(false)}> <PinOffIcon /> Unpin row </DropdownMenuItem> ) : null} </DropdownMenuContent> </DropdownMenu> ); }),]);
const data = makePeople(40, 31);
/** One row starts pinned to the top. Pin more from the row menu; they stay put while the rest scroll. */export default function DataGridRowPinning() { return ( <DataGrid key="row-pinning" data={data} columns={columns} getRowId={(row) => row.id} rowPinning pagination={false} height={400} initialState={{ rowPinning: { top: [data[4]!.id], bottom: [] } }} /> );}Use it
Section titled “Use it”Turn it on with rowPinning, then call row.pin("top" | "bottom" | false) from a
row-actions menu; row.getIsPinned() tells you the current state
so the menu can offer the right items:
col.actions(({ row }) => { const pinned = row.getIsPinned() // "top" | "bottom" | false return ( <DropdownMenu modal={false}> <DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Row actions" />}> <MoreHorizontalIcon /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {pinned !== "top" && <DropdownMenuItem onClick={() => row.pin("top")}>Pin row to top</DropdownMenuItem>} {pinned !== "bottom" && <DropdownMenuItem onClick={() => row.pin("bottom")}>Pin row to bottom</DropdownMenuItem>} {pinned && <DropdownMenuItem onClick={() => row.pin(false)}>Unpin row</DropdownMenuItem>} </DropdownMenuContent> </DropdownMenu> )})
<DataGrid data={data} columns={columns} getRowId={(row) => row.id} rowPinning height={400} />Start with rows pinned through initialState.rowPinning: { top: [ids], bottom: [ids] }. From code:
table.setRowPinning(...), table.resetRowPinning(), table.getTopRows(), table.getCenterRows(),
table.getBottomRows(). labels.pinRowTop, pinRowBottom and unpinRow hold the suggested
wording.
How it behaves
Section titled “How it behaves”-
Where they render. The body is the top-pinned rows, then the centre rows (the page, or every row without a pager), then the bottom-pinned rows. A top-pinned row is
position: stickyjust under the header, with a border along its bottom edge; a bottom-pinned row sticks to the bottom of the scroll area with a border along its top. Both have an opaque background and carrydata-pinned="top"or"bottom"for your styling. -
They survive filters and pages. The grid sets
keepPinnedRows: true: a pinned row stays visible when a filter would remove it and shows on every page. Passtable={{ keepPinnedRows: false }}to let filters and paging hide it like any other row. -
Trees.
row.pin(position, includeLeafRows, includeParentRows)can take a row’s descendants or its ancestors along. -
Not remembered. Row pinning is about particular rows, not the user’s view of the grid, so it is not part of the persisted view and resets on reload. Control
table.state.rowPinningwithtable.onRowPinningChangeif you want to keep it. -
Everything else works in a pinned row: selection, editing, cell selection, the row menu.
-
Pinned rows live outside the virtual list. With
virtualizethey are always in the DOM and stay stuck at their edge however far the rest scrolls. -
Several per edge stack. Each top-pinned row sticks just below the ones pinned before it, and each bottom-pinned row just above the ones after it (and above the footer, when there is one).
Options
Section titled “Options”Grid options:
| Option | Type | Default | What it does |
|---|---|---|---|
rowPinning |
boolean |
false |
Enables row.pin and the sticky rendering. |
initialState.rowPinning |
RowPinningState |
empty | Rows pinned at the start, by id: { top: ["p1"], bottom: [] }. |
table.enableRowPinning |
boolean or predicate |
rowPinning |
(row) => boolean decides per row (row.getCanPin()). |
table.keepPinnedRows |
boolean |
true |
Show pinned rows even when filtered or paged out. |
Row api (TanStack): row.pin(position, includeLeafRows?, includeParentRows?), row.getIsPinned(),
row.getCanPin(), row.getPinnedIndex().
See also
Section titled “See also”- Row actions: the menu the pin items live in.
- Column pinning: the same idea for columns.
- Virtualization.