Skip to content

Column DnD Table

Drag-and-drop column reordering with composable primitives.

Open in
"use client"
import * as React from "react"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import { DataTableEmptyBody } from "@/components/niko-table/core/data-table-structure"
import {
DataTableDndHeader,
DataTableDndColumnBody,
} from "@/components/niko-table/core/data-table-dnd-structure"
import { DataTableColumnDndProvider } from "@/components/niko-table/components/data-table-column-dnd"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Inbox } from "lucide-react"
// Types
type Employee = {
id: string
name: string
department: string
role: string
location: string
status: "active" | "on-leave" | "remote"
}
// Sample data
const data: Employee[] = [
{
id: "EMP-001",
name: "Alice Johnson",
department: "Engineering",
role: "Senior Developer",
location: "New York",
status: "active",
},
{
id: "EMP-002",
name: "Bob Smith",
department: "Design",
role: "UI Designer",
location: "San Francisco",
status: "remote",
},
{
id: "EMP-003",
name: "Carol Williams",
department: "Marketing",
role: "Marketing Lead",
location: "Chicago",
status: "active",
},
{
id: "EMP-004",
name: "David Brown",
department: "Engineering",
role: "Backend Developer",
location: "Austin",
status: "on-leave",
},
{
id: "EMP-005",
name: "Eva Martinez",
department: "Product",
role: "Product Manager",
location: "Seattle",
status: "active",
},
{
id: "EMP-006",
name: "Frank Lee",
department: "Engineering",
role: "DevOps Engineer",
location: "Denver",
status: "remote",
},
{
id: "EMP-007",
name: "Grace Kim",
department: "Design",
role: "UX Researcher",
location: "Portland",
status: "active",
},
{
id: "EMP-008",
name: "Henry Davis",
department: "Sales",
role: "Account Executive",
location: "Boston",
status: "active",
},
]
// Status badge variant helper
const getStatusVariant = (status: Employee["status"]) => {
switch (status) {
case "active":
return "default"
case "remote":
return "secondary"
case "on-leave":
return "outline"
default:
return "secondary"
}
}
// Column definitions
const columns: DataTableColumnDef<Employee>[] = [
{
accessorKey: "id",
id: "id",
header: "Employee ID",
size: 120,
},
{
accessorKey: "name",
id: "name",
header: "Name",
size: 160,
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "department",
id: "department",
header: "Department",
size: 140,
},
{
accessorKey: "role",
id: "role",
header: "Role",
size: 170,
},
{
accessorKey: "location",
id: "location",
header: "Location",
size: 140,
},
{
accessorKey: "status",
id: "status",
header: "Status",
size: 110,
cell: ({ row }) => {
const status = row.getValue("status") as Employee["status"]
return <Badge variant={getStatusVariant(status)}>{status}</Badge>
},
},
]
export default function ColumnDndExample() {
const [columnOrder, setColumnOrder] = React.useState(() =>
columns.map(c => c.id as string),
)
return (
<DataTableRoot
data={data}
columns={columns}
state={{ columnOrder }}
onColumnOrderChange={setColumnOrder}
>
<DataTableColumnDndProvider
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
>
<DataTable>
<DataTableDndHeader />
<DataTableDndColumnBody>
<DataTableEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<Inbox className="size-12" />
</DataTableEmptyIcon>
<DataTableEmptyTitle>No employees found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no employees to display at this time.
</DataTableEmptyDescription>
</DataTableEmptyMessage>
</DataTableEmptyBody>
</DataTableDndColumnBody>
</DataTable>
</DataTableColumnDndProvider>
</DataTableRoot>
)
}
Preview with Controlled State
Open in
"use client"
import * as React from "react"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import { DataTableEmptyBody } from "@/components/niko-table/core/data-table-structure"
import {
DataTableDndHeader,
DataTableDndColumnBody,
} from "@/components/niko-table/core/data-table-dnd-structure"
import { DataTableColumnDndProvider } from "@/components/niko-table/components/data-table-column-dnd"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
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 { Inbox } from "lucide-react"
// Types
type Employee = {
id: string
name: string
department: string
role: string
location: string
status: "active" | "on-leave" | "remote"
}
// Sample data
const data: Employee[] = [
{
id: "EMP-001",
name: "Alice Johnson",
department: "Engineering",
role: "Senior Developer",
location: "New York",
status: "active",
},
{
id: "EMP-002",
name: "Bob Smith",
department: "Design",
role: "UI Designer",
location: "San Francisco",
status: "remote",
},
{
id: "EMP-003",
name: "Carol Williams",
department: "Marketing",
role: "Marketing Lead",
location: "Chicago",
status: "active",
},
{
id: "EMP-004",
name: "David Brown",
department: "Engineering",
role: "Backend Developer",
location: "Austin",
status: "on-leave",
},
{
id: "EMP-005",
name: "Eva Martinez",
department: "Product",
role: "Product Manager",
location: "Seattle",
status: "active",
},
{
id: "EMP-006",
name: "Frank Lee",
department: "Engineering",
role: "DevOps Engineer",
location: "Denver",
status: "remote",
},
{
id: "EMP-007",
name: "Grace Kim",
department: "Design",
role: "UX Researcher",
location: "Portland",
status: "active",
},
{
id: "EMP-008",
name: "Henry Davis",
department: "Sales",
role: "Account Executive",
location: "Boston",
status: "active",
},
]
// Status badge variant helper
const getStatusVariant = (status: Employee["status"]) => {
switch (status) {
case "active":
return "default"
case "remote":
return "secondary"
case "on-leave":
return "outline"
default:
return "secondary"
}
}
// Column definitions
const columns: DataTableColumnDef<Employee>[] = [
{
accessorKey: "id",
id: "id",
header: "Employee ID",
size: 120,
},
{
accessorKey: "name",
id: "name",
header: "Name",
size: 160,
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "department",
id: "department",
header: "Department",
size: 140,
},
{
accessorKey: "role",
id: "role",
header: "Role",
size: 170,
},
{
accessorKey: "location",
id: "location",
header: "Location",
size: 140,
},
{
accessorKey: "status",
id: "status",
header: "Status",
size: 110,
cell: ({ row }) => {
const status = row.getValue("status") as Employee["status"]
return <Badge variant={getStatusVariant(status)}>{status}</Badge>
},
},
]
const initialColumnOrder = columns.map(c => c.id as string)
export default function ColumnDndStateExample() {
const [columnOrder, setColumnOrder] =
React.useState<string[]>(initialColumnOrder)
const resetColumnOrder = () => setColumnOrder(initialColumnOrder)
return (
<div className="w-full space-y-4">
<DataTableRoot
data={data}
columns={columns}
state={{ columnOrder }}
onColumnOrderChange={setColumnOrder}
>
<DataTableColumnDndProvider
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
>
<DataTable>
<DataTableDndHeader />
<DataTableDndColumnBody>
<DataTableEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<Inbox className="size-12" />
</DataTableEmptyIcon>
<DataTableEmptyTitle>No employees found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no employees to display at this time.
</DataTableEmptyDescription>
</DataTableEmptyMessage>
</DataTableEmptyBody>
</DataTableDndColumnBody>
</DataTable>
</DataTableColumnDndProvider>
</DataTableRoot>
{/* State Display */}
<Card>
<CardHeader>
<CardTitle>Current Column Order</CardTitle>
<CardDescription>
Drag column headers to reorder. The order is tracked in state.
</CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={resetColumnOrder}>
Reset Order
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-xs text-muted-foreground">
<div className="flex justify-between">
<span className="font-medium">Total Columns:</span>
<span className="text-foreground">{columnOrder.length}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Current Order:</span>
<span className="text-foreground">{columnOrder.join(" → ")}</span>
</div>
</div>
<details className="mt-4 border-t pt-4">
<summary className="cursor-pointer text-xs font-medium hover:text-foreground">
View Full State
</summary>
<pre className="mt-2 overflow-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(columnOrder, null, 2)}
</pre>
</details>
</CardContent>
</Card>
</div>
)
}

For large datasets, use DataTableVirtualizedDndHeader and DataTableVirtualizedDndColumnBody which combine row virtualization with column drag-and-drop.

Open in
"use client"
import * as React from "react"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
import { DataTableVirtualizedEmptyBody } from "@/components/niko-table/core/data-table-virtualized-structure"
import {
DataTableVirtualizedDndHeader,
DataTableVirtualizedDndColumnBody,
} from "@/components/niko-table/core/data-table-virtualized-dnd-structure"
import { DataTableColumnDndProvider } from "@/components/niko-table/components/data-table-column-dnd"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Inbox } from "lucide-react"
// Types
type Employee = {
id: string
name: string
email: string
department: string
role: string
location: string
status: "active" | "on-leave" | "remote"
salary: number
}
// Generate large dataset for virtualization demo
const generateEmployees = (count: number): Employee[] => {
const firstNames = [
"Alice",
"Bob",
"Carol",
"David",
"Eva",
"Frank",
"Grace",
"Henry",
"Ivy",
"Jack",
"Karen",
"Leo",
"Mia",
"Noah",
"Olivia",
"Paul",
"Quinn",
"Ruby",
"Sam",
"Tina",
]
const lastNames = [
"Johnson",
"Smith",
"Williams",
"Brown",
"Martinez",
"Lee",
"Kim",
"Davis",
"Wilson",
"Taylor",
"Anderson",
"Thomas",
"Jackson",
"White",
"Harris",
]
const departments = [
"Engineering",
"Design",
"Marketing",
"Sales",
"Product",
"HR",
"Finance",
"Operations",
]
const roles = [
"Senior Developer",
"UI Designer",
"Marketing Lead",
"Backend Developer",
"Product Manager",
"DevOps Engineer",
"UX Researcher",
"Account Executive",
"Data Analyst",
"QA Engineer",
]
const locations = [
"New York",
"San Francisco",
"Chicago",
"Austin",
"Seattle",
"Denver",
"Portland",
"Boston",
"Miami",
"Atlanta",
]
const statuses: Employee["status"][] = ["active", "on-leave", "remote"]
return Array.from({ length: count }, (_, i) => {
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)]
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)]
return {
id: `EMP-${String(i + 1).padStart(4, "0")}`,
name: `${firstName} ${lastName}`,
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${i}@company.com`,
department: departments[Math.floor(Math.random() * departments.length)],
role: roles[Math.floor(Math.random() * roles.length)],
location: locations[Math.floor(Math.random() * locations.length)],
status: statuses[Math.floor(Math.random() * statuses.length)],
salary: Math.floor(Math.random() * 100000) + 50000,
}
})
}
const data = generateEmployees(500)
// Status badge variant helper
const getStatusVariant = (status: Employee["status"]) => {
switch (status) {
case "active":
return "default"
case "remote":
return "secondary"
case "on-leave":
return "outline"
default:
return "secondary"
}
}
// Column definitions — all columns need explicit `id` for column ordering
const columns: DataTableColumnDef<Employee>[] = [
{
accessorKey: "id",
id: "id",
header: "Employee ID",
size: 120,
},
{
accessorKey: "name",
id: "name",
header: "Name",
size: 170,
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
},
{
accessorKey: "email",
id: "email",
header: "Email",
size: 250,
cell: ({ row }) => (
<div className="text-muted-foreground">{row.getValue("email")}</div>
),
},
{
accessorKey: "department",
id: "department",
header: "Department",
size: 140,
},
{
accessorKey: "role",
id: "role",
header: "Role",
size: 170,
},
{
accessorKey: "location",
id: "location",
header: "Location",
size: 140,
},
{
accessorKey: "status",
id: "status",
header: "Status",
size: 110,
cell: ({ row }) => {
const status = row.getValue("status") as Employee["status"]
return <Badge variant={getStatusVariant(status)}>{status}</Badge>
},
},
{
accessorKey: "salary",
id: "salary",
header: "Salary",
size: 120,
cell: ({ row }) => {
const salary = row.getValue("salary") as number
return <div className="font-mono">${salary.toLocaleString()}</div>
},
},
]
export default function VirtualizedColumnDndExample() {
const [columnOrder, setColumnOrder] = React.useState(() =>
columns.map(c => c.id as string),
)
return (
<DataTableRoot
data={data}
columns={columns}
state={{ columnOrder }}
onColumnOrderChange={setColumnOrder}
>
<DataTableColumnDndProvider
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
>
<DataTable height={500}>
<DataTableVirtualizedDndHeader />
<DataTableVirtualizedDndColumnBody estimateSize={40} overscan={10}>
<DataTableVirtualizedEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<Inbox className="size-12" />
</DataTableEmptyIcon>
<DataTableEmptyTitle>No employees found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no employees to display at this time.
</DataTableEmptyDescription>
</DataTableEmptyMessage>
</DataTableVirtualizedEmptyBody>
</DataTableVirtualizedDndColumnBody>
</DataTable>
</DataTableColumnDndProvider>
</DataTableRoot>
)
}

Column DnD lets users reorder table columns by dragging headers. Built with @dnd-kit and composable primitives following the shadcn/ui open-code pattern.

Install the DataTable core and column DnD add-on:

pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-column-dnd

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.

Three components work together:

  1. DataTableColumnDndProvider — Wraps the table with horizontal DnD context
  2. DataTableDndHeader — Renders headers as draggable items
  3. DataTableDndColumnBody — Cells follow column drag position
column-dnd.tsx
const [columnOrder, setColumnOrder] = React.useState<string[]>(() =>
columns.map(c => c.id as string),
)
return (
<DataTableRoot
data={data}
columns={columns}
state={{ columnOrder }}
onColumnOrderChange={setColumnOrder}
>
<DataTableColumnDndProvider
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
>
<DataTable>
<DataTableDndHeader />
<DataTableDndColumnBody />
</DataTable>
</DataTableColumnDndProvider>
</DataTableRoot>
)
  • Every column needs an explicit id field for column order tracking
  • columnOrder state must be passed to both DataTableRoot and DataTableColumnDndProvider
  • Headers are draggable by default — grab any header to reorder
  • Safe to combine with sorting and filtering — column order is independent of data order, unlike Row DnD

✅ Use Column DnD when:

  • Users want to customize their table layout
  • Different users prefer different column arrangements
  • Building configurable dashboards

❌ Consider other options when:

  • Column order is fixed by design
  • Tables have very few columns