AdminKitDocs
Plugins

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.

Pro

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:

PackageVersionUsed for
@dnd-kit/core6.3.1DndContext, sensors, DragOverlay, useDroppable
@dnd-kit/sortable10.0.0SortableContext, useSortable, arrayMove
@dnd-kit/utilities3.2.2CSS.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.

src/components/sortable-list.tsx
"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:

src/app/(dashboard)/checklist/page.tsx
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:

  • DndContext has id="task-board". Without an id, 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 DragOverlay goes to document.body through createPortal, and document does not exist on the server. So the board renders the portal only once useMounted() from @/hooks/use-mounted returns true, 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.

PartWhat it does
DndContextOne for the board, with closestCorners collision detection
SensorsMouse after 4px; touch after a 150ms press with 5px tolerance, so swipes still scroll; keyboard with sortableKeyboardCoordinates
SortableContextOne per column: its task keys, verticalListSortingStrategy
useDroppableEach column's list is a drop target, also when it is empty
useSortableEach card; 150ms ease transition, none under prefers-reduced-motion
DragOverlayThe 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.
  • screenReaderInstructions explain these keys, and announcements read out the task and its column and position at each step.
  • The board passes only tabIndex, aria-roledescription and aria-describedby from useSortable's attributes, so the cards keep their <article> role instead of role="button".
  • Under prefers-reduced-motion, cards and the overlay move without animation.

Last updated on

On this page