Docs
Column DnD Table
Column DnD Table
Drag-and-drop column reordering with composable primitives.
Overview
Section titled “Overview”1"use client"2
3import * as React from "react"4import { DataTableRoot } from "@/components/niko-table/core/data-table-root"5import { DataTable } from "@/components/niko-table/core/data-table"6import { DataTableEmptyBody } from "@/components/niko-table/core/data-table-structure"7import {8 DataTableDndHeader,9 DataTableDndColumnBody,10} from "@/components/niko-table/core/data-table-dnd-structure"11import { DataTableColumnDndProvider } from "@/components/niko-table/components/data-table-column-dnd"12import {13 DataTableEmptyIcon,14 DataTableEmptyMessage,15 DataTableEmptyTitle,16 DataTableEmptyDescription,17} from "@/components/niko-table/components/data-table-empty-state"18import type { DataTableColumnDef } from "@/components/niko-table/types"19import { Badge } from "@/components/ui/badge"20import { Inbox } from "lucide-react"21
22// Types23type Employee = {24 id: string25 name: string26 department: string27 role: string28 location: string29 status: "active" | "on-leave" | "remote"30}31
32// Sample data33const data: Employee[] = [34 {35 id: "EMP-001",36 name: "Alice Johnson",37 department: "Engineering",38 role: "Senior Developer",39 location: "New York",40 status: "active",41 },42 {43 id: "EMP-002",44 name: "Bob Smith",45 department: "Design",46 role: "UI Designer",47 location: "San Francisco",48 status: "remote",49 },50 {51 id: "EMP-003",52 name: "Carol Williams",53 department: "Marketing",54 role: "Marketing Lead",55 location: "Chicago",56 status: "active",57 },58 {59 id: "EMP-004",60 name: "David Brown",61 department: "Engineering",62 role: "Backend Developer",63 location: "Austin",64 status: "on-leave",65 },66 {67 id: "EMP-005",68 name: "Eva Martinez",69 department: "Product",70 role: "Product Manager",71 location: "Seattle",72 status: "active",73 },74 {75 id: "EMP-006",76 name: "Frank Lee",77 department: "Engineering",78 role: "DevOps Engineer",79 location: "Denver",80 status: "remote",81 },82 {83 id: "EMP-007",84 name: "Grace Kim",85 department: "Design",86 role: "UX Researcher",87 location: "Portland",88 status: "active",89 },90 {91 id: "EMP-008",92 name: "Henry Davis",93 department: "Sales",94 role: "Account Executive",95 location: "Boston",96 status: "active",97 },98]99
100// Status badge variant helper101const getStatusVariant = (status: Employee["status"]) => {102 switch (status) {103 case "active":104 return "default"105 case "remote":106 return "secondary"107 case "on-leave":108 return "outline"109 default:110 return "secondary"111 }112}113
114// Column definitions115const columns: DataTableColumnDef<Employee>[] = [116 {117 accessorKey: "id",118 id: "id",119 header: "Employee ID",120 size: 120,121 },122 {123 accessorKey: "name",124 id: "name",125 header: "Name",126 size: 160,127 cell: ({ row }) => (128 <div className="font-medium">{row.getValue("name")}</div>129 ),130 },131 {132 accessorKey: "department",133 id: "department",134 header: "Department",135 size: 140,136 },137 {138 accessorKey: "role",139 id: "role",140 header: "Role",141 size: 170,142 },143 {144 accessorKey: "location",145 id: "location",146 header: "Location",147 size: 140,148 },149 {150 accessorKey: "status",151 id: "status",152 header: "Status",153 size: 110,154 cell: ({ row }) => {155 const status = row.getValue("status") as Employee["status"]156 return <Badge variant={getStatusVariant(status)}>{status}</Badge>157 },158 },159]160
161export default function ColumnDndExample() {162 const [columnOrder, setColumnOrder] = React.useState(() =>163 columns.map(c => c.id as string),164 )165
166 return (167 <DataTableRoot168 data={data}169 columns={columns}170 state={{ columnOrder }}171 onColumnOrderChange={setColumnOrder}172 >173 <DataTableColumnDndProvider174 columnOrder={columnOrder}175 onColumnOrderChange={setColumnOrder}176 >177 <DataTable>178 <DataTableDndHeader />179 <DataTableDndColumnBody>180 <DataTableEmptyBody>181 <DataTableEmptyMessage>182 <DataTableEmptyIcon>183 <Inbox className="size-12" />184 </DataTableEmptyIcon>185 <DataTableEmptyTitle>No employees found</DataTableEmptyTitle>186 <DataTableEmptyDescription>187 There are no employees to display at this time.188 </DataTableEmptyDescription>189 </DataTableEmptyMessage>190 </DataTableEmptyBody>191 </DataTableDndColumnBody>192 </DataTable>193 </DataTableColumnDndProvider>194 </DataTableRoot>195 )196}| Employee ID | Name | Department | Role | Location | Status |
|---|---|---|---|---|---|
| EMP-001 | Alice Johnson | Engineering | Senior Developer | New York | active |
| EMP-002 | Bob Smith | Design | UI Designer | San Francisco | remote |
| EMP-003 | Carol Williams | Marketing | Marketing Lead | Chicago | active |
| EMP-004 | David Brown | Engineering | Backend Developer | Austin | on-leave |
| EMP-005 | Eva Martinez | Product | Product Manager | Seattle | active |
| EMP-006 | Frank Lee | Engineering | DevOps Engineer | Denver | remote |
| EMP-007 | Grace Kim | Design | UX Researcher | Portland | active |
| EMP-008 | Henry Davis | Sales | Account Executive | Boston | active |
Preview with Controlled State
1"use client"2
3import * as React from "react"4import { DataTableRoot } from "@/components/niko-table/core/data-table-root"5import { DataTable } from "@/components/niko-table/core/data-table"6import { DataTableEmptyBody } from "@/components/niko-table/core/data-table-structure"7import {8 DataTableDndHeader,9 DataTableDndColumnBody,10} from "@/components/niko-table/core/data-table-dnd-structure"11import { DataTableColumnDndProvider } from "@/components/niko-table/components/data-table-column-dnd"12import {13 DataTableEmptyIcon,14 DataTableEmptyMessage,15 DataTableEmptyTitle,16 DataTableEmptyDescription,17} from "@/components/niko-table/components/data-table-empty-state"18import type { DataTableColumnDef } from "@/components/niko-table/types"19import { Badge } from "@/components/ui/badge"20import { Button } from "@/components/ui/button"21import {22 Card,23 CardAction,24 CardContent,25 CardDescription,26 CardHeader,27 CardTitle,28} from "@/components/ui/card"29import { Inbox } from "lucide-react"30
31// Types32type Employee = {33 id: string34 name: string35 department: string36 role: string37 location: string38 status: "active" | "on-leave" | "remote"39}40
41// Sample data42const data: Employee[] = [43 {44 id: "EMP-001",45 name: "Alice Johnson",46 department: "Engineering",47 role: "Senior Developer",48 location: "New York",49 status: "active",50 },51 {52 id: "EMP-002",53 name: "Bob Smith",54 department: "Design",55 role: "UI Designer",56 location: "San Francisco",57 status: "remote",58 },59 {60 id: "EMP-003",61 name: "Carol Williams",62 department: "Marketing",63 role: "Marketing Lead",64 location: "Chicago",65 status: "active",66 },67 {68 id: "EMP-004",69 name: "David Brown",70 department: "Engineering",71 role: "Backend Developer",72 location: "Austin",73 status: "on-leave",74 },75 {76 id: "EMP-005",77 name: "Eva Martinez",78 department: "Product",79 role: "Product Manager",80 location: "Seattle",81 status: "active",82 },83 {84 id: "EMP-006",85 name: "Frank Lee",86 department: "Engineering",87 role: "DevOps Engineer",88 location: "Denver",89 status: "remote",90 },91 {92 id: "EMP-007",93 name: "Grace Kim",94 department: "Design",95 role: "UX Researcher",96 location: "Portland",97 status: "active",98 },99 {100 id: "EMP-008",101 name: "Henry Davis",102 department: "Sales",103 role: "Account Executive",104 location: "Boston",105 status: "active",106 },107]108
109// Status badge variant helper110const getStatusVariant = (status: Employee["status"]) => {111 switch (status) {112 case "active":113 return "default"114 case "remote":115 return "secondary"116 case "on-leave":117 return "outline"118 default:119 return "secondary"120 }121}122
123// Column definitions124const columns: DataTableColumnDef<Employee>[] = [125 {126 accessorKey: "id",127 id: "id",128 header: "Employee ID",129 size: 120,130 },131 {132 accessorKey: "name",133 id: "name",134 header: "Name",135 size: 160,136 cell: ({ row }) => (137 <div className="font-medium">{row.getValue("name")}</div>138 ),139 },140 {141 accessorKey: "department",142 id: "department",143 header: "Department",144 size: 140,145 },146 {147 accessorKey: "role",148 id: "role",149 header: "Role",150 size: 170,151 },152 {153 accessorKey: "location",154 id: "location",155 header: "Location",156 size: 140,157 },158 {159 accessorKey: "status",160 id: "status",161 header: "Status",162 size: 110,163 cell: ({ row }) => {164 const status = row.getValue("status") as Employee["status"]165 return <Badge variant={getStatusVariant(status)}>{status}</Badge>166 },167 },168]169
170const initialColumnOrder = columns.map(c => c.id as string)171
172export default function ColumnDndStateExample() {173 const [columnOrder, setColumnOrder] =174 React.useState<string[]>(initialColumnOrder)175
176 const resetColumnOrder = () => setColumnOrder(initialColumnOrder)177
178 return (179 <div className="w-full space-y-4">180 <DataTableRoot181 data={data}182 columns={columns}183 state={{ columnOrder }}184 onColumnOrderChange={setColumnOrder}185 >186 <DataTableColumnDndProvider187 columnOrder={columnOrder}188 onColumnOrderChange={setColumnOrder}189 >190 <DataTable>191 <DataTableDndHeader />192 <DataTableDndColumnBody>193 <DataTableEmptyBody>194 <DataTableEmptyMessage>195 <DataTableEmptyIcon>196 <Inbox className="size-12" />197 </DataTableEmptyIcon>198 <DataTableEmptyTitle>No employees found</DataTableEmptyTitle>199 <DataTableEmptyDescription>200 There are no employees to display at this time.201 </DataTableEmptyDescription>202 </DataTableEmptyMessage>203 </DataTableEmptyBody>204 </DataTableDndColumnBody>205 </DataTable>206 </DataTableColumnDndProvider>207 </DataTableRoot>208
209 {/* State Display */}210 <Card>211 <CardHeader>212 <CardTitle>Current Column Order</CardTitle>213 <CardDescription>214 Drag column headers to reorder. The order is tracked in state.215 </CardDescription>216 <CardAction>217 <Button variant="outline" size="sm" onClick={resetColumnOrder}>218 Reset Order219 </Button>220 </CardAction>221 </CardHeader>222 <CardContent>223 <div className="grid gap-2 text-xs text-muted-foreground">224 <div className="flex justify-between">225 <span className="font-medium">Total Columns:</span>226 <span className="text-foreground">{columnOrder.length}</span>227 </div>228 <div className="flex justify-between">229 <span className="font-medium">Current Order:</span>230 <span className="text-foreground">{columnOrder.join(" → ")}</span>231 </div>232 </div>233
234 <details className="mt-4 border-t pt-4">235 <summary className="cursor-pointer text-xs font-medium hover:text-foreground">236 View Full State237 </summary>238 <pre className="mt-2 overflow-auto rounded bg-muted p-2 text-xs">239 {JSON.stringify(columnOrder, null, 2)}240 </pre>241 </details>242 </CardContent>243 </Card>244 </div>245 )246}| Employee ID | Name | Department | Role | Location | Status |
|---|---|---|---|---|---|
| EMP-001 | Alice Johnson | Engineering | Senior Developer | New York | active |
| EMP-002 | Bob Smith | Design | UI Designer | San Francisco | remote |
| EMP-003 | Carol Williams | Marketing | Marketing Lead | Chicago | active |
| EMP-004 | David Brown | Engineering | Backend Developer | Austin | on-leave |
| EMP-005 | Eva Martinez | Product | Product Manager | Seattle | active |
| EMP-006 | Frank Lee | Engineering | DevOps Engineer | Denver | remote |
| EMP-007 | Grace Kim | Design | UX Researcher | Portland | active |
| EMP-008 | Henry Davis | Sales | Account Executive | Boston | active |
Current Column Order
Drag column headers to reorder. The order is tracked in state.
Total Columns:6
Current Order:id → name → department → role → location → status
View Full State
[ "id", "name", "department", "role", "location", "status" ]
Virtualized Column DnD
Section titled “Virtualized Column DnD”For large datasets, use DataTableVirtualizedDndHeader and DataTableVirtualizedDndColumnBody which combine row virtualization with column drag-and-drop.
1"use client"2
3import * as React from "react"4import { DataTableRoot } from "@/components/niko-table/core/data-table-root"5import { DataTable } from "@/components/niko-table/core/data-table"6import { DataTableVirtualizedEmptyBody } from "@/components/niko-table/core/data-table-virtualized-structure"7import {8 DataTableVirtualizedDndHeader,9 DataTableVirtualizedDndColumnBody,10} from "@/components/niko-table/core/data-table-virtualized-dnd-structure"11import { DataTableColumnDndProvider } from "@/components/niko-table/components/data-table-column-dnd"12import {13 DataTableEmptyIcon,14 DataTableEmptyMessage,15 DataTableEmptyTitle,16 DataTableEmptyDescription,17} from "@/components/niko-table/components/data-table-empty-state"18import type { DataTableColumnDef } from "@/components/niko-table/types"19import { Badge } from "@/components/ui/badge"20import { Inbox } from "lucide-react"21
22// Types23type Employee = {24 id: string25 name: string26 email: string27 department: string28 role: string29 location: string30 status: "active" | "on-leave" | "remote"31 salary: number32}33
34// Generate large dataset for virtualization demo35const generateEmployees = (count: number): Employee[] => {36 const firstNames = [37 "Alice",38 "Bob",39 "Carol",40 "David",41 "Eva",42 "Frank",43 "Grace",44 "Henry",45 "Ivy",46 "Jack",47 "Karen",48 "Leo",49 "Mia",50 "Noah",51 "Olivia",52 "Paul",53 "Quinn",54 "Ruby",55 "Sam",56 "Tina",57 ]58 const lastNames = [59 "Johnson",60 "Smith",61 "Williams",62 "Brown",63 "Martinez",64 "Lee",65 "Kim",66 "Davis",67 "Wilson",68 "Taylor",69 "Anderson",70 "Thomas",71 "Jackson",72 "White",73 "Harris",74 ]75 const departments = [76 "Engineering",77 "Design",78 "Marketing",79 "Sales",80 "Product",81 "HR",82 "Finance",83 "Operations",84 ]85 const roles = [86 "Senior Developer",87 "UI Designer",88 "Marketing Lead",89 "Backend Developer",90 "Product Manager",91 "DevOps Engineer",92 "UX Researcher",93 "Account Executive",94 "Data Analyst",95 "QA Engineer",96 ]97 const locations = [98 "New York",99 "San Francisco",100 "Chicago",101 "Austin",102 "Seattle",103 "Denver",104 "Portland",105 "Boston",106 "Miami",107 "Atlanta",108 ]109 const statuses: Employee["status"][] = ["active", "on-leave", "remote"]110
111 return Array.from({ length: count }, (_, i) => {112 const firstName = firstNames[Math.floor(Math.random() * firstNames.length)]113 const lastName = lastNames[Math.floor(Math.random() * lastNames.length)]114 return {115 id: `EMP-${String(i + 1).padStart(4, "0")}`,116 name: `${firstName} ${lastName}`,117 email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}${i}@company.com`,118 department: departments[Math.floor(Math.random() * departments.length)],119 role: roles[Math.floor(Math.random() * roles.length)],120 location: locations[Math.floor(Math.random() * locations.length)],121 status: statuses[Math.floor(Math.random() * statuses.length)],122 salary: Math.floor(Math.random() * 100000) + 50000,123 }124 })125}126
127const data = generateEmployees(500)128
129// Status badge variant helper130const getStatusVariant = (status: Employee["status"]) => {131 switch (status) {132 case "active":133 return "default"134 case "remote":135 return "secondary"136 case "on-leave":137 return "outline"138 default:139 return "secondary"140 }141}142
143// Column definitions — all columns need explicit `id` for column ordering144const columns: DataTableColumnDef<Employee>[] = [145 {146 accessorKey: "id",147 id: "id",148 header: "Employee ID",149 size: 120,150 },151 {152 accessorKey: "name",153 id: "name",154 header: "Name",155 size: 170,156 cell: ({ row }) => (157 <div className="font-medium">{row.getValue("name")}</div>158 ),159 },160 {161 accessorKey: "email",162 id: "email",163 header: "Email",164 size: 250,165 cell: ({ row }) => (166 <div className="text-muted-foreground">{row.getValue("email")}</div>167 ),168 },169 {170 accessorKey: "department",171 id: "department",172 header: "Department",173 size: 140,174 },175 {176 accessorKey: "role",177 id: "role",178 header: "Role",179 size: 170,180 },181 {182 accessorKey: "location",183 id: "location",184 header: "Location",185 size: 140,186 },187 {188 accessorKey: "status",189 id: "status",190 header: "Status",191 size: 110,192 cell: ({ row }) => {193 const status = row.getValue("status") as Employee["status"]194 return <Badge variant={getStatusVariant(status)}>{status}</Badge>195 },196 },197 {198 accessorKey: "salary",199 id: "salary",200 header: "Salary",201 size: 120,202 cell: ({ row }) => {203 const salary = row.getValue("salary") as number204 return <div className="font-mono">${salary.toLocaleString()}</div>205 },206 },207]208
209export default function VirtualizedColumnDndExample() {210 const [columnOrder, setColumnOrder] = React.useState(() =>211 columns.map(c => c.id as string),212 )213
214 return (215 <DataTableRoot216 data={data}217 columns={columns}218 state={{ columnOrder }}219 onColumnOrderChange={setColumnOrder}220 >221 <DataTableColumnDndProvider222 columnOrder={columnOrder}223 onColumnOrderChange={setColumnOrder}224 >225 <DataTable height={500}>226 <DataTableVirtualizedDndHeader />227 <DataTableVirtualizedDndColumnBody estimateSize={40} overscan={10}>228 <DataTableVirtualizedEmptyBody>229 <DataTableEmptyMessage>230 <DataTableEmptyIcon>231 <Inbox className="size-12" />232 </DataTableEmptyIcon>233 <DataTableEmptyTitle>No employees found</DataTableEmptyTitle>234 <DataTableEmptyDescription>235 There are no employees to display at this time.236 </DataTableEmptyDescription>237 </DataTableEmptyMessage>238 </DataTableVirtualizedEmptyBody>239 </DataTableVirtualizedDndColumnBody>240 </DataTable>241 </DataTableColumnDndProvider>242 </DataTableRoot>243 )244}| Employee ID | Name | Department | Role | Location | Status | Salary |
|---|
Introduction
Section titled “Introduction”Column DnD lets users reorder table columns by dragging headers. Built with @dnd-kit and composable primitives following the shadcn/ui open-code pattern.
Installation
Section titled “Installation”Install the DataTable core and column DnD add-on:
pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-column-dndFirst 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.
Basic Column DnD
Section titled “Basic Column DnD”Three components work together:
DataTableColumnDndProvider— Wraps the table with horizontal DnD contextDataTableDndHeader— Renders headers as draggable itemsDataTableDndColumnBody— Cells follow column drag position
1const [columnOrder, setColumnOrder] = React.useState<string[]>(() =>2 columns.map(c => c.id as string),3)4
5return (6 <DataTableRoot7 data={data}8 columns={columns}9 state={{ columnOrder }}10 onColumnOrderChange={setColumnOrder}11 >12 <DataTableColumnDndProvider13 columnOrder={columnOrder}14 onColumnOrderChange={setColumnOrder}15 >16 <DataTable>17 <DataTableDndHeader />18 <DataTableDndColumnBody />19 </DataTable>20 </DataTableColumnDndProvider>21 </DataTableRoot>22)Key Points
Section titled “Key Points”- Every column needs an explicit
idfield for column order tracking columnOrderstate must be passed to bothDataTableRootandDataTableColumnDndProvider- 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
When to Use
Section titled “When to Use”✅ 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
Next Steps
Section titled “Next Steps”- Row DnD Table — Drag-and-drop row reordering
- Virtualization Table — Virtual scrolling for large datasets
- Column Pinning Table — Pin columns to edges