Next.js Drag and Drop with dnd kit
Next.js drag and drop with dnd kit in a client component: the AdminKit PRO task board, hydration-safe ids and overlay, and mouse, touch and keyboard sorting.
This feature is only available in AdminKit PRO. Learn more.
A task board feels natural when people can pick up a card and drop it where it belongs. The tasks page of AdminKit for Next.js does that with dnd kit. You drag cards up and down a column, or across to another one.
Dragging runs in the browser, so the board, src/components/pages/apps/tasks/task-board.tsx, is a client component, a file that starts with "use client". It uses dnd kit directly. There is no wrapper in src/components/plugins/. The page, src/app/(dashboard)/pages-tasks/page.tsx, stays a server component. It exports the metadata and renders <TaskBoard />.
AdminKit PRO installs three dnd kit packages:
| Package | Version | Used for |
|---|---|---|
@dnd-kit/core | 6.3.1 | DndContext, sensors, DragOverlay, useDroppable |
@dnd-kit/sortable | 10.0.0 | SortableContext, useSortable, arrayMove |
@dnd-kit/utilities | 3.2.2 | CSS.Translate.toString() for the drag transform |
The docs cannot run dnd kit, so the examples here are code only. The tasks demo shows the board.
Example
Start with a list people can reorder. Hooks and drag handlers run in the browser, so the file starts with "use client". The server still renders it: the HTML that Next.js builds has the list in its first order. dnd kit takes over after hydration, when the browser has taken over that HTML.
"use client";
import {
closestCorners,
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import { cx } from "@/lib/cx";
function SortableItem({ id }: { id: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
return (
<div
ref={setNodeRef}
className={cx("list-group-item cursor-grab", isDragging && "sortable-ghost")}
style={{ transform: CSS.Translate.toString(transform), transition }}
{...attributes}
{...listeners}
>
{id}
</div>
);
}
export function SortableList({ initialItems }: { initialItems: string[] }) {
const [items, setItems] = useState(initialItems);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (over && active.id !== over.id) {
setItems((current) =>
arrayMove(current, current.indexOf(String(active.id)), current.indexOf(String(over.id)))
);
}
};
return (
// A fixed id keeps dnd kit's accessibility ids the same on the server and in the browser
<DndContext
id="sortable-list"
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={onDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="list-group">
{items.map((item) => (
<SortableItem key={item} id={item} />
))}
</div>
</SortableContext>
</DndContext>
);
}Your page passes the items as plain data, so it stays a server component:
import { SortableList } from "@/components/sortable-list";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata("/checklist");
export default function Page() {
return (
<SortableList
initialItems={["Write release notes", "Review pull request", "Update dependencies"]}
/>
);
}.sortable-ghost fades the dragged item to 40% opacity, and .cursor-grab shows the grab cursor. Both come with AdminKit's Sass. When you add a route such as /checklist, add it to src/data/pages.json too, where pageMetadata() reads it.
Server rendering and hydration
The HTML from the server and the first render in the browser must match, or React reports a hydration error. Two details in task-board.tsx make sure they do:
DndContexthasid="task-board". Without anid, dnd kit numbers the ids of its screen reader elements (aria-describedby) with a counter. That count can differ between the server and the browser.- The
DragOverlaygoes todocument.bodythroughcreatePortal, anddocumentdoes not exist on the server. So the board renders the portal only onceuseMounted()from@/hooks/use-mountedreturnstrue, which happens after hydration:
const mounted = useMounted();
// ...and in the JSX of the board:
{mounted &&
createPortal(
<DragOverlay dropAnimation={reducedMotion ? null : undefined}>
{active && tasks[active] && (
<TaskCard task={tasks[active]} className="sortable-drag mb-0" />
)}
</DragOverlay>,
document.body
)}How the task board works
The board keeps the tasks by key and the task keys of each column, a Record<ColumnId, string[]>. Both start from src/mocks/tasks.ts. That state only lives in the browser. The app builds to static files, so there is no server to save to. To keep the changes, send them to your API with fetch from the drag handlers.
| Part | What it does |
|---|---|
DndContext | One for the board, with closestCorners collision detection |
| Sensors | Mouse after 4px; touch after a 150ms press with 5px tolerance, so swipes still scroll; keyboard with sortableKeyboardCoordinates |
SortableContext | One per column: its task keys, verticalListSortingStrategy |
useDroppable | Each column's list is a drop target, also when it is empty |
useSortable | Each card; 150ms ease transition, none under prefers-reduced-motion |
DragOverlay | The card under the pointer (.sortable-drag); no drop animation under reduced motion |
When a drag starts, onDragStart saves the board. onDragOver moves the task into the column it is over. onDragEnd reorders within a column with arrayMove. If the user cancels, onDragCancel restores the saved board.
Accessibility
- With the keyboard: Tab to a card, Space or Enter to pick it up, arrow keys to move it within or between columns, Space or Enter to drop, Escape to cancel.
screenReaderInstructionsexplain these keys, andannouncementsread out the task and its column and position at each step.- The board passes only
tabIndex,aria-roledescriptionandaria-describedbyfromuseSortable'sattributes, so the cards keep their<article>role instead ofrole="button". - Under
prefers-reduced-motion, cards and the overlay move without animation.
Related
- React Drag and Drop with dnd kit: the same board in AdminKit for React (Vite)
- Bootstrap 5 drag and drop: the HTML version, with SortableJS
- Card: the cards of the task board
- Dropdowns: the column menus of the board
- Tasks demo
Last updated on
Vector map
A Next.js vector map for the App Router: jsVectorMap in a client component, fed marker data from a server component page, with colors that follow dark mode.
Chart setup
Next.js charts in client components: the ChartJs, ApexChart (PRO) and VectorMap components of AdminKit, with colors that follow light and dark mode.