Skip to content

Row DnD Table

Drag-and-drop row 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 {
DataTableHeader,
DataTableEmptyBody,
} from "@/components/niko-table/core/data-table-structure"
import { DataTableDndBody } from "@/components/niko-table/core/data-table-dnd-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import {
DataTableRowDndProvider,
DataTableRowDragHandle,
} from "@/components/niko-table/components/data-table-row-dnd"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Inbox } from "lucide-react"
// Types
type Task = {
id: string
title: string
status: "todo" | "in-progress" | "done" | "cancelled"
priority: "low" | "medium" | "high"
}
// Sample data
const initialData: Task[] = [
{
id: "TASK-001",
title: "Set up project repository",
status: "done",
priority: "high",
},
{
id: "TASK-002",
title: "Design database schema",
status: "in-progress",
priority: "high",
},
{
id: "TASK-003",
title: "Implement authentication",
status: "todo",
priority: "medium",
},
{
id: "TASK-004",
title: "Create API endpoints",
status: "todo",
priority: "medium",
},
{
id: "TASK-005",
title: "Write unit tests",
status: "todo",
priority: "low",
},
{
id: "TASK-006",
title: "Set up CI/CD pipeline",
status: "cancelled",
priority: "low",
},
{
id: "TASK-007",
title: "Deploy to staging",
status: "todo",
priority: "medium",
},
{
id: "TASK-008",
title: "Performance optimization",
status: "in-progress",
priority: "high",
},
]
// Status badge variant helper
const getStatusVariant = (status: Task["status"]) => {
switch (status) {
case "done":
return "default"
case "in-progress":
return "secondary"
case "todo":
return "outline"
case "cancelled":
return "destructive"
default:
return "secondary"
}
}
// Priority badge variant helper
const getPriorityVariant = (priority: Task["priority"]) => {
switch (priority) {
case "high":
return "destructive"
case "medium":
return "secondary"
case "low":
return "outline"
default:
return "secondary"
}
}
export default function RowDndExample() {
const [data, setData] = React.useState(initialData)
const columns: DataTableColumnDef<Task>[] = React.useMemo(
() => [
{
id: "drag-handle",
size: 40,
header: () => null,
cell: ({ row }) => <DataTableRowDragHandle rowId={row.id} />,
enableSorting: false,
enableHiding: false,
},
{
id: "order",
size: 50,
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="#" />
</DataTableColumnHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.index + 1}</span>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Task ID" />
</DataTableColumnHeader>
),
meta: { label: "Task ID" },
size: 110,
},
{
accessorKey: "title",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Title" />
</DataTableColumnHeader>
),
meta: { label: "Title" },
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Status" />
</DataTableColumnHeader>
),
meta: { label: "Status" },
size: 130,
cell: ({ row }) => {
const status = row.getValue("status") as Task["status"]
return <Badge variant={getStatusVariant(status)}>{status}</Badge>
},
},
{
accessorKey: "priority",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Priority" />
</DataTableColumnHeader>
),
meta: { label: "Priority" },
size: 110,
cell: ({ row }) => {
const priority = row.getValue("priority") as Task["priority"]
return (
<Badge variant={getPriorityVariant(priority)}>{priority}</Badge>
)
},
},
],
[],
)
return (
<DataTableRoot data={data} columns={columns} getRowId={row => row.id}>
<DataTableRowDndProvider data={data} onReorder={setData}>
<DataTable>
<DataTableHeader />
<DataTableDndBody>
<DataTableEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<Inbox className="size-12" />
</DataTableEmptyIcon>
<DataTableEmptyTitle>No tasks found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no tasks to display at this time.
</DataTableEmptyDescription>
</DataTableEmptyMessage>
</DataTableEmptyBody>
</DataTableDndBody>
</DataTable>
</DataTableRowDndProvider>
</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 {
DataTableHeader,
DataTableEmptyBody,
} from "@/components/niko-table/core/data-table-structure"
import { DataTableDndBody } from "@/components/niko-table/core/data-table-dnd-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import {
DataTableRowDndProvider,
DataTableRowDragHandle,
} from "@/components/niko-table/components/data-table-row-dnd"
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 Task = {
id: string
title: string
status: "todo" | "in-progress" | "done" | "cancelled"
priority: "low" | "medium" | "high"
}
// Sample data
const initialData: Task[] = [
{
id: "TASK-001",
title: "Set up project repository",
status: "done",
priority: "high",
},
{
id: "TASK-002",
title: "Design database schema",
status: "in-progress",
priority: "high",
},
{
id: "TASK-003",
title: "Implement authentication",
status: "todo",
priority: "medium",
},
{
id: "TASK-004",
title: "Create API endpoints",
status: "todo",
priority: "medium",
},
{
id: "TASK-005",
title: "Write unit tests",
status: "todo",
priority: "low",
},
{
id: "TASK-006",
title: "Set up CI/CD pipeline",
status: "cancelled",
priority: "low",
},
{
id: "TASK-007",
title: "Deploy to staging",
status: "todo",
priority: "medium",
},
{
id: "TASK-008",
title: "Performance optimization",
status: "in-progress",
priority: "high",
},
]
// Status badge variant helper
const getStatusVariant = (status: Task["status"]) => {
switch (status) {
case "done":
return "default"
case "in-progress":
return "secondary"
case "todo":
return "outline"
case "cancelled":
return "destructive"
default:
return "secondary"
}
}
// Priority badge variant helper
const getPriorityVariant = (priority: Task["priority"]) => {
switch (priority) {
case "high":
return "destructive"
case "medium":
return "secondary"
case "low":
return "outline"
default:
return "secondary"
}
}
export default function RowDndStateExample() {
const [data, setData] = React.useState(initialData)
const columns: DataTableColumnDef<Task>[] = React.useMemo(
() => [
{
id: "drag-handle",
size: 40,
header: () => null,
cell: ({ row }) => <DataTableRowDragHandle rowId={row.id} />,
enableSorting: false,
enableHiding: false,
},
{
id: "order",
size: 50,
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="#" />
</DataTableColumnHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.index + 1}</span>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Task ID" />
</DataTableColumnHeader>
),
meta: { label: "Task ID" },
size: 110,
},
{
accessorKey: "title",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Title" />
</DataTableColumnHeader>
),
meta: { label: "Title" },
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Status" />
</DataTableColumnHeader>
),
meta: { label: "Status" },
size: 130,
cell: ({ row }) => {
const status = row.getValue("status") as Task["status"]
return <Badge variant={getStatusVariant(status)}>{status}</Badge>
},
},
{
accessorKey: "priority",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Priority" />
</DataTableColumnHeader>
),
meta: { label: "Priority" },
size: 110,
cell: ({ row }) => {
const priority = row.getValue("priority") as Task["priority"]
return (
<Badge variant={getPriorityVariant(priority)}>{priority}</Badge>
)
},
},
],
[],
)
const resetData = () => setData(initialData)
return (
<div className="w-full space-y-4">
<DataTableRoot data={data} columns={columns} getRowId={row => row.id}>
<DataTableRowDndProvider data={data} onReorder={setData}>
<DataTable>
<DataTableHeader />
<DataTableDndBody>
<DataTableEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<Inbox className="size-12" />
</DataTableEmptyIcon>
<DataTableEmptyTitle>No tasks found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no tasks to display at this time.
</DataTableEmptyDescription>
</DataTableEmptyMessage>
</DataTableEmptyBody>
</DataTableDndBody>
</DataTable>
</DataTableRowDndProvider>
</DataTableRoot>
{/* State Display */}
<Card>
<CardHeader>
<CardTitle>Current Row Order</CardTitle>
<CardDescription>
Drag rows to reorder. The order is tracked in state.
</CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={resetData}>
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 Items:</span>
<span className="text-foreground">{data.length}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Current Order:</span>
<span className="text-foreground">
{data.map(d => d.id).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(
data.map(d => ({ id: d.id, title: d.title })),
null,
2,
)}
</pre>
</details>
</CardContent>
</Card>
</div>
)
}

For large datasets, use DataTableVirtualizedDndBody which combines row virtualization with drag-and-drop. Only visible rows are rendered in the DOM for optimal performance.

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 {
DataTableVirtualizedFlexHeader,
DataTableVirtualizedEmptyBody,
} from "@/components/niko-table/core/data-table-virtualized-structure"
import { DataTableVirtualizedDndBody } from "@/components/niko-table/core/data-table-virtualized-dnd-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import {
DataTableEmptyIcon,
DataTableEmptyMessage,
DataTableEmptyTitle,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import {
DataTableRowDndProvider,
DataTableRowDragHandle,
} from "@/components/niko-table/components/data-table-row-dnd"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Inbox } from "lucide-react"
// Types
type Task = {
id: string
title: string
status: "todo" | "in-progress" | "done" | "cancelled"
priority: "low" | "medium" | "high"
assignee: string
}
// Generate large dataset for virtualization demo
const generateTasks = (count: number): Task[] => {
const titles = [
"Set up project repository",
"Design database schema",
"Implement authentication",
"Create API endpoints",
"Write unit tests",
"Set up CI/CD pipeline",
"Deploy to staging",
"Performance optimization",
"Code review",
"Update documentation",
"Fix login bug",
"Add search feature",
"Refactor user module",
"Create dashboard",
"Implement caching",
"Add error handling",
"Set up monitoring",
"Write integration tests",
"Optimize queries",
"Add pagination",
]
const statuses: Task["status"][] = [
"todo",
"in-progress",
"done",
"cancelled",
]
const priorities: Task["priority"][] = ["low", "medium", "high"]
const assignees = [
"Alice",
"Bob",
"Carol",
"David",
"Eva",
"Frank",
"Grace",
"Henry",
"Ivy",
"Jack",
]
return Array.from({ length: count }, (_, i) => ({
id: `TASK-${String(i + 1).padStart(4, "0")}`,
title: titles[i % titles.length],
status: statuses[Math.floor(Math.random() * statuses.length)],
priority: priorities[Math.floor(Math.random() * priorities.length)],
assignee: assignees[Math.floor(Math.random() * assignees.length)],
}))
}
const initialData = generateTasks(500)
// Status badge variant helper
const getStatusVariant = (status: Task["status"]) => {
switch (status) {
case "done":
return "default"
case "in-progress":
return "secondary"
case "todo":
return "outline"
case "cancelled":
return "destructive"
default:
return "secondary"
}
}
// Priority badge variant helper
const getPriorityVariant = (priority: Task["priority"]) => {
switch (priority) {
case "high":
return "destructive"
case "medium":
return "secondary"
case "low":
return "outline"
default:
return "secondary"
}
}
export default function VirtualizedRowDndExample() {
const [data, setData] = React.useState(initialData)
const columns: DataTableColumnDef<Task>[] = React.useMemo(
() => [
{
id: "drag-handle",
size: 40,
header: () => null,
cell: ({ row }) => <DataTableRowDragHandle rowId={row.id} />,
enableSorting: false,
enableHiding: false,
},
{
id: "order",
size: 50,
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="#" />
</DataTableColumnHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.index + 1}</span>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Task ID" />
</DataTableColumnHeader>
),
meta: { label: "Task ID" },
size: 110,
},
{
accessorKey: "title",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Title" />
</DataTableColumnHeader>
),
meta: { label: "Title" },
},
{
accessorKey: "assignee",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Assignee" />
</DataTableColumnHeader>
),
meta: { label: "Assignee" },
size: 120,
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Status" />
</DataTableColumnHeader>
),
meta: { label: "Status" },
size: 130,
cell: ({ row }) => {
const status = row.getValue("status") as Task["status"]
return <Badge variant={getStatusVariant(status)}>{status}</Badge>
},
},
{
accessorKey: "priority",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Priority" />
</DataTableColumnHeader>
),
meta: { label: "Priority" },
size: 110,
cell: ({ row }) => {
const priority = row.getValue("priority") as Task["priority"]
return (
<Badge variant={getPriorityVariant(priority)}>{priority}</Badge>
)
},
},
],
[],
)
return (
<DataTableRoot data={data} columns={columns} getRowId={row => row.id}>
<DataTableRowDndProvider data={data} onReorder={setData}>
<DataTable height={500}>
<DataTableVirtualizedFlexHeader />
<DataTableVirtualizedDndBody estimateSize={40} overscan={10}>
<DataTableVirtualizedEmptyBody>
<DataTableEmptyMessage>
<DataTableEmptyIcon>
<Inbox className="size-12" />
</DataTableEmptyIcon>
<DataTableEmptyTitle>No tasks found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no tasks to display at this time.
</DataTableEmptyDescription>
</DataTableEmptyMessage>
</DataTableVirtualizedEmptyBody>
</DataTableVirtualizedDndBody>
</DataTable>
</DataTableRowDndProvider>
</DataTableRoot>
)
}

Row DnD lets users reorder table rows by dragging. Built with @dnd-kit and composable primitives that follow the shadcn/ui open-code pattern — copy the code, modify freely.

Install the DataTable core and row DnD add-on:

pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-row-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.

We’ll build a task board with draggable rows:

type Task = {
id: string
title: string
status: "todo" | "in-progress" | "done" | "cancelled"
priority: "low" | "medium" | "high"
}

Three components work together:

  1. DataTableRowDndProvider — Wraps the table with DnD context and sensors
  2. DataTableDndBody — Renders rows as draggable items
  3. DataTableRowDragHandle — A grip icon button for dragging
row-dnd.tsx
const [data, setData] = React.useState(initialData)
const columns: DataTableColumnDef<Task>[] = [
{
id: "drag-handle",
size: 40,
header: () => null,
cell: ({ row }) => <DataTableRowDragHandle rowId={row.id} />,
enableSorting: false,
enableHiding: false,
},
// ... other columns
]
return (
<DataTableRoot data={data} columns={columns} getRowId={(row) => row.id}>
<DataTableRowDndProvider data={data} onReorder={setData}>
<DataTable>
<DataTableHeader />
<DataTableDndBody />
</DataTable>
</DataTableRowDndProvider>
</DataTableRoot>
)
  • getRowId is required — use stable, unique IDs (e.g., database IDs), not array indexes. Array indexes break DnD after reordering because the index no longer matches the original item
  • DataTableRowDndProvider must wrap outside <DataTable> — DnD context creates <div> elements that can’t be inside <table>
  • onReorder receives the new data array after arrayMove — just pass setData

The drag handle is a dedicated column with DataTableRowDragHandle:

{
id: "drag-handle",
size: 40,
header: () => null,
cell: ({ row }) => <DataTableRowDragHandle rowId={row.id} />,
enableSorting: false,
enableHiding: false,
}

Track the data order externally:

row-dnd-state.tsx
const [data, setData] = React.useState(initialData)
// Reset to original order
const resetData = () => setData(initialData)
return (
<DataTableRoot data={data} columns={columns} getRowId={(row) => row.id}>
<DataTableRowDndProvider data={data} onReorder={setData}>
<DataTable>
<DataTableHeader />
<DataTableDndBody />
</DataTable>
</DataTableRowDndProvider>
</DataTableRoot>
)

Don’t combine sorting or filtering with row DnD. Sorting and filtering override the manual row order — if a user drags row 3 to position 1, then a sort or filter resets it, the reorder is lost.

  • Avoid DataTableColumnSortMenu, DataTableSearchFilter, and DataTableFacetedFilter in DnD tables
  • If you need search, consider filtering the source data before passing it to the table
  • Column DnD is safe to combine with sorting/filtering since column order is independent of data order

✅ Use Row DnD when:

  • Users need to manually prioritize or reorder items
  • Building kanban boards, task lists, or playlist managers
  • Order matters and should be persisted

❌ Consider other options when:

  • Data has a natural sort order (use sorting instead)
  • The table is read-only and order doesn’t matter