Column Pinning Table
Pin columns to the left or right edge for easy reference while scrolling.
"use client"
import { DataTableRoot, DataTable, DataTableHeader, DataTableBody, DataTableEmptyBody,} from "@/components/niko-table/core"import { DataTableToolbarSection, DataTablePagination, DataTableSearchFilter, DataTableViewMenu, DataTableEmptyIcon, DataTableEmptyMessage, DataTableEmptyTitle, DataTableEmptyDescription, DataTableColumnHeader, DataTableColumnTitle, DataTableColumnActions, DataTableColumnSortOptions, DataTableColumnPinOptions,} from "@/components/niko-table/components"import { FILTER_VARIANTS } from "@/components/niko-table/lib"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { MoreHorizontal, PackageSearch } from "lucide-react"
// Typestype Order = { id: string customer: string product: string amount: number status: "pending" | "shipped" | "delivered" | "cancelled" date: string region: string}
// Sample data with enough columns to demonstrate horizontal scrollingconst data: Order[] = [ { id: "ORD-001", customer: "John Doe", product: "Premium Widget", amount: 299.99, status: "delivered", date: "2024-01-15", region: "North America", }, { id: "ORD-002", customer: "Jane Smith", product: "Basic Kit", amount: 149.5, status: "shipped", date: "2024-01-18", region: "Europe", }, { id: "ORD-003", customer: "Bob Johnson", product: "Pro Bundle", amount: 599.0, status: "pending", date: "2024-01-20", region: "Asia Pacific", }, { id: "ORD-004", customer: "Alice Williams", product: "Starter Pack", amount: 79.99, status: "delivered", date: "2024-01-22", region: "North America", }, { id: "ORD-005", customer: "Charlie Brown", product: "Enterprise Suite", amount: 1299.0, status: "shipped", date: "2024-01-25", region: "Europe", }, { id: "ORD-006", customer: "Diana Prince", product: "Premium Widget", amount: 299.99, status: "cancelled", date: "2024-01-28", region: "Asia Pacific", }, { id: "ORD-007", customer: "Ethan Hunt", product: "Basic Kit", amount: 149.5, status: "pending", date: "2024-02-01", region: "North America", }, { id: "ORD-008", customer: "Fiona Green", product: "Pro Bundle", amount: 599.0, status: "delivered", date: "2024-02-05", region: "Europe", }, { id: "ORD-009", customer: "George Miller", product: "Starter Pack", amount: 79.99, status: "shipped", date: "2024-02-08", region: "Asia Pacific", }, { id: "ORD-010", customer: "Hannah Lee", product: "Enterprise Suite", amount: 1299.0, status: "delivered", date: "2024-02-12", region: "North America", },]
// Status badge variant helperconst getStatusVariant = (status: Order["status"]) => { switch (status) { case "delivered": return "default" case "shipped": return "secondary" case "pending": return "outline" case "cancelled": return "destructive" default: return "secondary" }}
// Columns with composable DataTableColumnActions patternconst columns: DataTableColumnDef<Order>[] = [ { accessorKey: "id", size: 110, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Order ID" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Order ID" }, }, { accessorKey: "customer", size: 160, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Customer" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Customer" }, }, { accessorKey: "product", size: 180, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Product" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Product" }, }, { accessorKey: "amount", size: 120, meta: { label: "Amount", variant: FILTER_VARIANTS.NUMBER }, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Amount" /> <DataTableColumnActions> <DataTableColumnSortOptions variant={FILTER_VARIANTS.NUMBER} /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) }, }, { accessorKey: "status", size: 120, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Status" /> <DataTableColumnActions> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Status" }, cell: ({ row }) => { const status = row.getValue("status") as Order["status"] return <Badge variant={getStatusVariant(status)}>{status}</Badge> }, }, { accessorKey: "date", size: 130, meta: { label: "Date", variant: FILTER_VARIANTS.DATE }, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Date" /> <DataTableColumnActions> <DataTableColumnSortOptions variant={FILTER_VARIANTS.DATE} /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), cell: ({ row }) => { return new Date(row.getValue("date")).toLocaleDateString() }, }, { accessorKey: "region", size: 140, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Region" /> <DataTableColumnActions> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Region" }, }, { id: "actions", size: 70, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="" /> </DataTableColumnHeader> ), cell: ({ row }) => ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <span className="sr-only">Open menu</span> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.id)} > Copy order ID </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>View order</DropdownMenuItem> <DropdownMenuItem>Track shipment</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), },]
export default function ColumnPinningTable() { return ( <DataTableRoot data={data} columns={columns} initialState={{ columnPinning: { left: ["id"], right: ["actions"], }, }} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search orders..." /> <DataTableViewMenu /> </DataTableToolbarSection>
<DataTable> <DataTableHeader /> <DataTableBody> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <PackageSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No orders found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your search criteria. </DataTableEmptyDescription> </DataTableEmptyMessage> </DataTableEmptyBody> </DataTableBody> </DataTable>
<DataTablePagination /> </DataTableRoot> )}Preview with Controlled State
Current Table State
Live view of the current table state for demonstration purposes
Search Query:None
Total Items:10
Sorting:None
Page:1 (Size: 10)
Hidden Columns:0
Pinned Columns:1 Left, 1 Right
View Full State Object
Sorting:
[]
Column Visibility:
{}Column Pinning:
{
"left": [
"id"
],
"right": [
"actions"
]
}"use client"
import { useState } from "react"import type { PaginationState, SortingState, ColumnPinningState, VisibilityState,} from "@tanstack/react-table"import { DataTableRoot, DataTable, DataTableHeader, DataTableBody, DataTableEmptyBody,} from "@/components/niko-table/core"import { DataTableToolbarSection, DataTablePagination, DataTableSearchFilter, DataTableViewMenu, DataTableEmptyIcon, DataTableEmptyMessage, DataTableEmptyTitle, DataTableEmptyDescription, DataTableColumnHeader, DataTableColumnTitle, DataTableColumnActions, DataTableColumnSortOptions, DataTableColumnPinOptions,} from "@/components/niko-table/components"import { FILTER_VARIANTS } from "@/components/niko-table/lib"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { MoreHorizontal, PackageSearch } from "lucide-react"
// Typestype Order = { id: string customer: string product: string amount: number status: "pending" | "shipped" | "delivered" | "cancelled" date: string region: string}
// Sample dataconst data: Order[] = [ { id: "ORD-001", customer: "John Doe", product: "Premium Widget", amount: 299.99, status: "delivered", date: "2024-01-15", region: "North America", }, { id: "ORD-002", customer: "Jane Smith", product: "Basic Kit", amount: 149.5, status: "shipped", date: "2024-01-18", region: "Europe", }, { id: "ORD-003", customer: "Bob Johnson", product: "Pro Bundle", amount: 599.0, status: "pending", date: "2024-01-20", region: "Asia Pacific", }, { id: "ORD-004", customer: "Alice Williams", product: "Starter Pack", amount: 79.99, status: "delivered", date: "2024-01-22", region: "North America", }, { id: "ORD-005", customer: "Charlie Brown", product: "Enterprise Suite", amount: 1299.0, status: "shipped", date: "2024-01-25", region: "Europe", }, { id: "ORD-006", customer: "Diana Prince", product: "Premium Widget", amount: 299.99, status: "cancelled", date: "2024-01-28", region: "Asia Pacific", }, { id: "ORD-007", customer: "Ethan Hunt", product: "Basic Kit", amount: 149.5, status: "pending", date: "2024-02-01", region: "North America", }, { id: "ORD-008", customer: "Fiona Green", product: "Pro Bundle", amount: 599.0, status: "delivered", date: "2024-02-05", region: "Europe", }, { id: "ORD-009", customer: "George Miller", product: "Starter Pack", amount: 79.99, status: "shipped", date: "2024-02-08", region: "Asia Pacific", }, { id: "ORD-010", customer: "Hannah Lee", product: "Enterprise Suite", amount: 1299.0, status: "delivered", date: "2024-02-12", region: "North America", },]
// Status badge variant helperconst getStatusVariant = (status: Order["status"]) => { switch (status) { case "delivered": return "default" case "shipped": return "secondary" case "pending": return "outline" case "cancelled": return "destructive" default: return "secondary" }}
// Columns with pinning menu in each headerconst columns: DataTableColumnDef<Order>[] = [ { accessorKey: "id", size: 110, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Order ID" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Order ID" }, }, { accessorKey: "customer", size: 160, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Customer" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Customer" }, }, { accessorKey: "product", size: 180, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Product" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Product" }, }, { accessorKey: "amount", size: 120, meta: { label: "Amount", variant: FILTER_VARIANTS.NUMBER }, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Amount" /> <DataTableColumnActions> <DataTableColumnSortOptions variant={FILTER_VARIANTS.NUMBER} /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")) return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(amount) }, }, { accessorKey: "status", size: 120, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Status" /> <DataTableColumnActions> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Status" }, cell: ({ row }) => { const status = row.getValue("status") as Order["status"] return <Badge variant={getStatusVariant(status)}>{status}</Badge> }, }, { accessorKey: "date", size: 130, meta: { label: "Date", variant: FILTER_VARIANTS.DATE }, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Date" /> <DataTableColumnActions> <DataTableColumnSortOptions variant={FILTER_VARIANTS.DATE} /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), cell: ({ row }) => { return new Date(row.getValue("date")).toLocaleDateString() }, }, { accessorKey: "region", size: 140, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Region" /> <DataTableColumnActions> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ), meta: { label: "Region" }, }, { id: "actions", size: 70, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="" /> </DataTableColumnHeader> ), cell: ({ row }) => ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <span className="sr-only">Open menu</span> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.id)} > Copy order ID </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem>View order</DropdownMenuItem> <DropdownMenuItem>Track shipment</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), },]
export default function ColumnPinningStateTable() { const [globalFilter, setGlobalFilter] = useState<string | object>("") const [sorting, setSorting] = useState<SortingState>([]) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({ left: ["id"], right: ["actions"], }) const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10, })
const resetAllState = () => { setGlobalFilter("") setSorting([]) setColumnVisibility({}) setColumnPinning({ left: [], right: [] }) setPagination({ pageIndex: 0, pageSize: 10 }) }
return ( <div className="w-full space-y-4"> <DataTableRoot data={data} columns={columns} state={{ globalFilter, sorting, columnVisibility, columnPinning, pagination, }} onGlobalFilterChange={value => { setGlobalFilter(value) setPagination(prev => ({ ...prev, pageIndex: 0 })) }} onSortingChange={setSorting} onColumnVisibilityChange={setColumnVisibility} onColumnPinningChange={setColumnPinning} onPaginationChange={setPagination} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search orders..." /> <DataTableViewMenu /> </DataTableToolbarSection>
<DataTable> <DataTableHeader /> <DataTableBody> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <PackageSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No orders found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your search criteria. </DataTableEmptyDescription> </DataTableEmptyMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination /> </DataTableRoot>
{/* State Display for demonstration */} <Card> <CardHeader> <CardTitle>Current Table State</CardTitle> <CardDescription> Live view of the current table state for demonstration purposes </CardDescription> <CardAction> <Button variant="outline" size="sm" onClick={resetAllState}> Reset All State </Button> </CardAction> </CardHeader> <CardContent className="space-y-4"> <div className="grid gap-2 text-xs text-muted-foreground"> <div className="flex justify-between"> <span className="font-medium">Search Query:</span> <span className="text-foreground"> {typeof globalFilter === "string" ? globalFilter || "None" : "Mixed Filters"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Total Items:</span> <span className="text-foreground">{data.length}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Sorting:</span> <span className="text-foreground"> {sorting.length > 0 ? sorting .map(s => `${s.id} ${s.desc ? "desc" : "asc"}`) .join(", ") : "None"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Page:</span> <span className="text-foreground"> {pagination.pageIndex + 1} (Size: {pagination.pageSize}) </span> </div>
<div className="flex justify-between"> <span className="font-medium">Hidden Columns:</span> <span className="text-foreground"> { Object.values(columnVisibility).filter(v => v === false) .length } </span> </div>
<div className="flex justify-between"> <span className="font-medium">Pinned Columns:</span> <span className="text-foreground"> {columnPinning.left?.length || 0} Left,{" "} {columnPinning.right?.length || 0} Right </span> </div> </div>
{/* Detailed state (collapsible) */} <details className="border-t pt-4"> <summary className="cursor-pointer text-xs font-medium hover:text-foreground"> View Full State Object </summary> <div className="mt-4 space-y-3 text-xs"> <div> <strong>Sorting:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(sorting, null, 2)} </pre> </div> <div> <strong>Column Visibility:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnVisibility, null, 2)} </pre> </div> <div> <strong>Column Pinning:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnPinning, null, 2)} </pre> </div> </div> </details> </CardContent> </Card> </div> )}Introduction
Section titled “Introduction”Column pinning keeps important columns visible while users scroll horizontally. Pin identifier columns (like Order ID) to the left and action columns to the right.
Installation
Section titled “Installation”- Add the required components:
npx shadcn@latest add table input button dropdown-menu badge- Add
tanstack/react-tabledependency:
npm install @tanstack/react-table- Copy the DataTable components into your project. See the Installation Guide for detailed instructions.
Prerequisites
Section titled “Prerequisites”We’ll build a table showing orders. Here’s our data:
type Order = { id: string customer: string product: string amount: number status: "pending" | "shipped" | "delivered" | "cancelled" date: string region: string}
const data: Order[] = [ { id: "ORD-001", customer: "John Doe", product: "Premium Widget", amount: 299.99, status: "delivered", date: "2024-01-15", region: "North America", }, // ...]Basic Column Pinning
Section titled “Basic Column Pinning”Set initialState.columnPinning to pin columns on mount:
<DataTableRoot data={data} columns={columns} initialState={{ columnPinning: { left: ["id"], // Pin Order ID to left right: ["actions"], // Pin actions to right }, }}> <DataTable> <DataTableHeader /> <DataTableBody /> </DataTable></DataTableRoot>Column Definitions
Section titled “Column Definitions”Set explicit size for each column to enable horizontal scrolling:
const columns: DataTableColumnDef<Order>[] = [ { accessorKey: "id", size: 110, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Order ID" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), meta: { label: "Order ID" }, }, { accessorKey: "customer", size: 160, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Customer" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), meta: { label: "Customer" }, }, // ... more columns]Interactive Pinning
Section titled “Interactive Pinning”Add pinning options to column actions for user-controlled pinning:
{ accessorKey: "customer", size: 160, header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Customer" /> <DataTableColumnActions> <DataTableColumnSortOptions /> <DataTableColumnPinOptions /> </DataTableColumnActions> </DataTableColumnHeader> ),}Controlled State
Section titled “Controlled State”Manage pinning state externally:
import { useState } from "react"import { type ColumnPinningState } from "@tanstack/react-table"
export function ControlledPinningTable({ data }: { data: Order[] }) { const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({ left: ["id"], right: ["actions"], })
return ( <DataTableRoot data={data} columns={columns} state={{ columnPinning }} onColumnPinningChange={setColumnPinning} > {/* ... */} </DataTableRoot> )}When to Use
Section titled “When to Use”✅ Use Column Pinning when:
- Tables have many columns requiring horizontal scroll
- Key identifiers (ID, Name) must stay visible
- Actions column should be always accessible
❌ Consider other options when:
- All columns fit on screen (no scrolling needed)
- Mobile-first design (pinning adds complexity)
Next Steps
Section titled “Next Steps”- Virtualization Table - Combine pinning with virtual scrolling
- Advanced Table - Persistent pinning state