Row DnD Table
Drag-and-drop row reordering with composable primitives.
Overview
Section titled “Overview”"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"
// Typestype Task = { id: string title: string status: "todo" | "in-progress" | "done" | "cancelled" priority: "low" | "medium" | "high"}
// Sample dataconst 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 helperconst 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 helperconst 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
View Full State
[
{
"id": "TASK-001",
"title": "Set up project repository"
},
{
"id": "TASK-002",
"title": "Design database schema"
},
{
"id": "TASK-003",
"title": "Implement authentication"
},
{
"id": "TASK-004",
"title": "Create API endpoints"
},
{
"id": "TASK-005",
"title": "Write unit tests"
},
{
"id": "TASK-006",
"title": "Set up CI/CD pipeline"
},
{
"id": "TASK-007",
"title": "Deploy to staging"
},
{
"id": "TASK-008",
"title": "Performance optimization"
}
]"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"
// Typestype Task = { id: string title: string status: "todo" | "in-progress" | "done" | "cancelled" priority: "low" | "medium" | "high"}
// Sample dataconst 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 helperconst 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 helperconst 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> )}Virtualized Row DnD
Section titled “Virtualized Row DnD”For large datasets, use DataTableVirtualizedDndBody which combines row virtualization with drag-and-drop. Only visible rows are rendered in the DOM for optimal performance.
"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 { DataTableVirtualizedHeader, 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"
// Typestype Task = { id: string title: string status: "todo" | "in-progress" | "done" | "cancelled" priority: "low" | "medium" | "high" assignee: string}
// Generate large dataset for virtualization democonst 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 helperconst 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 helperconst 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}> <DataTableVirtualizedHeader /> <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> )}Introduction
Section titled “Introduction”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.
Installation
Section titled “Installation”Install the DataTable core and row DnD add-on:
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.
Prerequisites
Section titled “Prerequisites”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"}Basic Row DnD
Section titled “Basic Row DnD”Three components work together:
DataTableRowDndProvider— Wraps the table with DnD context and sensorsDataTableDndBody— Renders rows as draggable itemsDataTableRowDragHandle— A grip icon button for dragging
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>)Key Points
Section titled “Key Points”getRowIdis 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 itemDataTableRowDndProvidermust wrap outside<DataTable>— DnD context creates<div>elements that can’t be inside<table>onReorderreceives the new data array afterarrayMove— just passsetData
Drag Handle Column
Section titled “Drag Handle Column”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,}Controlled State
Section titled “Controlled State”Track the data order externally:
const [data, setData] = React.useState(initialData)
// Reset to original orderconst resetData = () => setData(initialData)
return ( <DataTableRoot data={data} columns={columns} getRowId={(row) => row.id}> <DataTableRowDndProvider data={data} onReorder={setData}> <DataTable> <DataTableHeader /> <DataTableDndBody /> </DataTable> </DataTableRowDndProvider> </DataTableRoot>)Best Practices
Section titled “Best Practices”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, andDataTableFacetedFilterin 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
When to Use
Section titled “When to Use”✅ 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
Next Steps
Section titled “Next Steps”- Column DnD Table — Drag-and-drop column reordering
- Row Selection Table — Combine with row selection