Skip to content

Column Pinning Table

Pin columns to the left or right edge for easy reference while scrolling.

Open in
"use client"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import {
DataTableHeader,
DataTableBody,
DataTableEmptyBody,
} from "@/components/niko-table/core/data-table-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import { DataTableColumnActions } from "@/components/niko-table/components/data-table-column-actions"
import { DataTableColumnSortOptions } from "@/components/niko-table/components/data-table-column-sort"
import { DataTableColumnPinOptions } from "@/components/niko-table/components/data-table-column-pin"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
import { FILTER_VARIANTS } from "@/components/niko-table/lib/constants"
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"
// Types
type 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 scrolling
const 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 helper
const 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 pattern
const 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
Open in
"use client"
import { useState } from "react"
import type {
PaginationState,
SortingState,
ColumnPinningState,
VisibilityState,
} from "@tanstack/react-table"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import {
DataTableHeader,
DataTableBody,
DataTableEmptyBody,
} from "@/components/niko-table/core/data-table-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import { DataTableColumnActions } from "@/components/niko-table/components/data-table-column-actions"
import { DataTableColumnSortOptions } from "@/components/niko-table/components/data-table-column-sort"
import { DataTableColumnPinOptions } from "@/components/niko-table/components/data-table-column-pin"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
import { FILTER_VARIANTS } from "@/components/niko-table/lib/constants"
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"
// Types
type Order = {
id: string
customer: string
product: string
amount: number
status: "pending" | "shipped" | "delivered" | "cancelled"
date: string
region: string
}
// Sample data
const 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 helper
const 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 header
const 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>
)
}

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.

Install the DataTable core and add-ons for this example:

pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-column-pin @niko-table/data-table-column-sort @niko-table/data-table-column-hide @niko-table/data-table-pagination @niko-table/data-table-search-filter @niko-table/data-table-view-menu

First time using @niko-table? See the Installation Guide to set up the registry.

For other add-ons or manual copy-paste, see the Installation Guide.

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",
},
// ...
]

Set initialState.columnPinning to pin columns on mount:

column-pinning.tsx
<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>

Set explicit size for each column to enable horizontal scrolling:

columns.tsx
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
]

Add pinning options to column actions for user-controlled pinning:

{
accessorKey: "customer",
size: 160,
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Customer" />
<DataTableColumnActions>
<DataTableColumnSortOptions />
<DataTableColumnPinOptions />
</DataTableColumnActions>
</DataTableColumnHeader>
),
}

Manage pinning state externally:

column-pinning-state.tsx
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>
)
}

✅ 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)