Frontend No. 052

Drag and Drop skill for Claude Code

Drag as a shortcut, with a click path behind it. Claude builds sortable lists and boards where dragging is optional: every move also works from a Move menu and the keyboard, and each one is announced to screen readers. It picks dnd-kit, Pragmatic drag and drop or native events with reasons, persists order as fractional keys in a byte-collated column, and rolls back optimistic moves when the save fails.

  • namedrag-and-drop
  • size254 lines · ≈3,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds accessible drag-and-drop interfaces — sortable lists, kanban boards, trees and file drop zones — choosing between dnd-kit, Pragmatic drag and drop and native HTML5 DnD, adding a single-pointer move menu for WCAG 2.2 SC 2.5.7, keyboard dragging and live-region announcements, touch activation rules, auto-scroll and drop indicators, and persisting order with fractional ordering keys and optimistic rollback. Use when adding reorder by drag, building a kanban or sortable list, migrating off react-beautiful-dnd, fixing drag on touch devices, or auditing drag-and-drop accessibility.

Try asking

  • Add drag-to-reorder to this task list and save the order to Postgres
  • Our kanban board uses react-beautiful-dnd. Migrate it and make it accessible
  • Dragging cards doesn't work on iPhone and the page won't scroll. Fix it

Use it when

  • Adding reorder by drag to a list, grid, tree or kanban board
  • Migrating off react-beautiful-dnd
  • Drag breaks scrolling or does nothing on touch devices
  • An audit flags WCAG 2.5.7 Dragging Movements or keyboard access
  • Reordered items jump back or conflict between users

Skip it when

How to check the result

Each outcome works by click-only menu, keyboard and drag with an announcement; order survives reload and concurrent sessions; blocking the save request puts the item back with a message.

The common mistake it prevents

Shipping keyboard dragging and calling it accessible: SC 2.5.7 needs a single-pointer path without dragging, such as a Move menu.

~/.claude/skills/drag-and-drop/SKILL.md

--- name: drag-and-drop description: Builds accessible drag-and-drop interfaces — sortable lists, kanban boards, trees and file drop zones — choosing between dnd-kit, Pragmatic… license: MIT ---

Dragging is a shortcut, never the only way. Done well means: every drag outcome can also be done with a single click or tap (no drag) and with the keyboard, each move is announced to screen readers, the drop position is visible before release, the new order survives a reload and a concurrent edit, and a failed save puts the item back with a message.

Ground rules

  • WCAG 2.2 SC 2.5.7 Dragging Movements (Level AA): all functionality that uses dragging must be achievable by a single pointer without dragging, unless dragging is essential. A keyboard alternative alone does not satisfy it — provide clickable controls (a “Move” menu, up/down buttons, a destination select).
  • Keyboard access (SC 2.1.1) is separate and also required: the move menu usually covers it; keyboard dragging is a bonus, not a substitute for the menu.
  • Persist order as ordering keys, not array indexes. A move writes one row.
  • Update the UI optimistically, save in the background, roll back on failure.
  • Draggable handles are real <button> elements with a name that includes the item (“Reorder Write tests”) and a target of at least 24×24 CSS px (SC 2.5.8).

Workflow

- [ ] 1. List every drag outcome (reorder, move between lists, nest, drop file)
- [ ] 2. Design the non-drag alternative for each outcome (menu items, buttons)
- [ ] 3. Choose the library (table below) and pin the version
- [ ] 4. Build the sortable/droppable UI with handle, drop indicator, auto-scroll
- [ ] 5. Add announcements and focus restoration
- [ ] 6. Persist with ordering keys; optimistic update plus rollback
- [ ] 7. Touch: activation constraint, touch-action, test on a real phone
- [ ] 8. Test: unit (order logic), e2e (drag and menu paths), screen reader pass

Choose the library

NeedChooseNotes
React sortable lists, boards, grids with keyboard dragging built in@dnd-kit/core 6.x + @dnd-kit/sortable 10.xPointer-event based: works on touch, keyboard sensor and announcements included. Most examples online use this API
Same, new API@dnd-kit/react (0.x) with @dnd-kit/dom and @dnd-kit/helpersPre-1.0; pin an exact version and read the changelog before upgrading
Framework-agnostic, large boards, cross-window or file and text drops@atlaskit/pragmatic-drag-and-drop 4.xSmall core over native HTML5 DnD; optional hitbox, auto-scroll, drop-indicator, live-region packages. No keyboard dragging — pair with a menu (its own guidance)
Only “drop a file here”Native dragover/drop events + <input type="file">The file input is the non-drag alternative; keep it visible
Existing react-beautiful-dndMigrate to Pragmatic drag and drop or dnd-kitreact-beautiful-dnd is deprecated and its repository archived

Check versions before writing code: npm view @dnd-kit/core version, npm view @atlaskit/pragmatic-drag-and-drop version.

For a complete Pragmatic drag and drop sortable list (closest edge, drop indicator, auto-scroll, live region), read references/pragmatic-recipe.md when that library is chosen.

Sortable list with dnd-kit (legacy stable API)

import {
  DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor,
  useSensor, useSensors, DragOverlay, type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext, sortableKeyboardCoordinates, useSortable,
  verticalListSortingStrategy, arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

function TaskList({ tasks, onReorder }: Props) {
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),       // click still works
    useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }), // scroll still works
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  function handleDragEnd({ active, over }: DragEndEvent) {
    if (!over || active.id === over.id) return;
    const from = tasks.findIndex((t) => t.id === active.id);
    const to = tasks.findIndex((t) => t.id === over.id);
    onReorder(arrayMove(tasks, from, to), active.id as string, to);
  }

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}
                accessibility={{ announcements, screenReaderInstructions }}>
      <SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
        <ul>{tasks.map((t) => <SortableTask key={t.id} task={t} />)}</ul>
      </SortableContext>
    </DndContext>
  );
}

function SortableTask({ task }: { task: Task }) {
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
    useSortable({ id: task.id });
  return (
    <li ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }}>
      <button ref={setActivatorNodeRef} {...attributes} {...listeners}
              aria-label={`Reorder ${task.title}`} className="drag-handle">⋮⋮</button>
      <span>{task.title}</span>
      <MoveMenu task={task} />   {/* single-pointer alternative: Move up / down / to top / to bottom */}
    </li>
  );
}
.drag-handle { touch-action: none; min-width: 24px; min-height: 24px; cursor: grab; }
  • Keyboard defaults: Space or Enter picks up, arrow keys move, Space or Enter drops, Escape cancels. Write announcements (onDragStart, onDragOver, onDragEnd, onDragCancel) with item names and positions (“Write tests moved to position 3 of 8”), and localize screenReaderInstructions; the defaults are English only.
  • DragOverlay renders the dragged item in a portal; use it for lists inside scroll containers or with overflow: hidden parents so the preview is not clipped.
  • Auto-scroll is on by default in DndContext (autoScroll prop) for scrollable ancestors.
  • New API (@dnd-kit/react): DragDropProvider, useSortable({ id, index }) from @dnd-kit/react/sortable; in onDragEnd return early on event.canceled, read source.initialIndex and source.index via isSortable(source), or call move(items, event) from @dnd-kit/helpers.

The single-pointer alternative

Put a menu button on each item (or make the handle a menu button when moving is the only action). Menu items per outcome:

OutcomeMenu items
ReorderMove up, Move down, Move to top, Move to bottom
Move between listsMove to → (list of destinations), or a dialog with a select
Nest in a treeIndent, Outdent, Move into…
  • Each action calls the same moveItem(id, destination) function the drop handler uses, so both paths share persistence and announcements.
  • Hide items that do nothing (“Move up” on the first item) or disable them with a reason.
  • After a menu move, return focus to the item’s menu button (re-query it if the item remounted in a new list) and announce the result in a polite live region.

Persisting order: ordering keys

Use a string ordering key per item and fractional-indexing:

import { generateKeyBetween } from "fractional-indexing";

// moving item to index `to` in the already-reordered array `next`
const before = next[to - 1]?.position ?? null;
const after = next[to + 1]?.position ?? null;
const position = generateKeyBetween(before, after);       // e.g. between "a1" and "a2" → "a1V"
await api.patch(`/tasks/${id}`, { position, listId });
-- Postgres: byte-order collation or keys sort wrong ("a1V" vs "a1v")
ALTER TABLE tasks ADD COLUMN position text COLLATE "C" NOT NULL;
CREATE INDEX tasks_list_position ON tasks (list_id, position, id);
SELECT * FROM tasks WHERE list_id = $1 ORDER BY position, id;
  • Keys are case-sensitive: compare with </> or plain sort(), never localeCompare.
  • Concurrent moves between the same neighbors can produce equal keys. Order by (position, id) so ties are stable, and have the server regenerate the key if it collides inside the list (or use a jittered variant of the algorithm).
  • Keys grow when users repeatedly insert at the same spot. When a key exceeds a length you choose (for example 50 chars), rebalance that list in one transaction with generateNKeysBetween(null, null, n).
  • Alternative for short, single-user lists (under a few hundred items): integer positions renumbered in one transaction. Never send the whole reordered array from the client as the source of truth in multi-user lists — the last writer silently reverts others.

Optimistic update with rollback (TanStack Query shown; same shape anywhere):

useMutation({
  mutationFn: (m: Move) => api.patch(`/tasks/${m.id}`, m),
  onMutate: async (m) => {
    await qc.cancelQueries({ queryKey: ["tasks", m.listId] });
    const prev = qc.getQueryData<Task[]>(["tasks", m.listId]);
    qc.setQueryData(["tasks", m.listId], applyMove(prev, m));
    return { prev };
  },
  onError: (_e, m, ctx) => { qc.setQueryData(["tasks", m.listId], ctx?.prev); toast("Could not move task. It is back where it was."); },
  onSettled: (_d, _e, m) => qc.invalidateQueries({ queryKey: ["tasks", m.listId] }),
});

Drop indicators and feedback

  • Show where the item will land before release: a line between items (Pragmatic closest-edge + drop indicator) or items shifting apart (dnd-kit sortable strategies). Pick one per product and keep it consistent.
  • Show invalid targets as invalid (cursor, muted target, no indicator) rather than accepting and rejecting after drop.
  • Animate the settle on drop (150–250 ms) so the eye can follow the item; respect prefers-reduced-motion.
  • Long lists: auto-scroll near container edges; for virtualized lists, check the library supports virtualization with sortable (dnd-kit does with caveats; test fast scrolling).

Touch

  • Use an activation constraint (delay ~250 ms with ~5 px tolerance on touch, distance ~8 px with a mouse) so taps and scrolls are not hijacked.
  • touch-action: none on the handle only, never on the whole item or list, or the page cannot scroll.
  • Native HTML5 DnD behavior on mobile browsers varies; if you rely on it (Pragmatic), test on real iOS Safari and Android Chrome, and make sure the menu works there.
  • Keep handles at least 24×24 CSS px; larger on touch-first products.

Gotchas

  • aria-roledescription="draggable" is announced instead of “button”; localize it, and never leave the handle as a non-focusable <div>.
  • Reordering an array by index while a filter or sort is applied moves the wrong item. Compute neighbors from the visible list, persist by id.
  • Rendering every item on each drag-over event makes large boards stutter; memoize items and keep drag state out of the list component’s props where possible.
  • Text selection starts when dragging from inside text: use a handle, or user-select: none on the draggable during drag.
  • Iframes swallow pointer events mid-drag; Pragmatic has a utility to block dragging into iframes, otherwise disable pointer events on iframes during drag.

Testing

  • Unit-test applyMove and key generation: first, last, middle, between lists, equal keys.
  • Playwright: await page.locator("#task-1 .drag-handle").dragTo(page.locator("#task-3")) works for HTML5 DnD. For pointer-event libraries use hover(), mouse.down(), then move to the target twice (the second move triggers dragover in all browsers), then mouse.up(); with an activation distance, move in several steps.
  • Test the menu path and the keyboard path in e2e, not just the drag path.
  • Assert the order after reload (persistence), and after a forced 500 (rollback + message).
  • Manual: VoiceOver or NVDA announces pick-up, position changes and drop; axe reports no violations on the list.

Verification

  1. Every outcome works with a mouse click only (no drag), with keyboard only, and by drag; each announces the result.
  2. Reload: order persists. Two browser sessions moving items concurrently converge to the same order after refresh.
  3. Block the PATCH request: the item returns to its original position with a message.
  4. On a real phone: scrolling the list does not start a drag; dragging by the handle works.

Report format

**Outcomes:** reorder within list, move between columns
**Library:** @dnd-kit/core 6.3.1 + sortable 10.0.0 (pinned)
**Non-drag path:** per-card Move menu (up/down/top/bottom/to column)
**Persistence:** position text COLLATE "C", fractional keys, optimistic + rollback
**Verified:** keyboard, menu, drag, VoiceOver, concurrent sessions, failed save

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/pragmatic-recipe.md 136 lines · 5.9 KB
# Pragmatic drag and drop — sortable list recipe

Read when the library choice is Pragmatic drag and drop. Import paths below match
`@atlaskit/pragmatic-drag-and-drop` 4.x, where the older `element/adapter` path is
deprecated in favor of `adapter/element-adapter`. Confirm against the installed version.

## Install

```bash
npm install @atlaskit/pragmatic-drag-and-drop \
  @atlaskit/pragmatic-drag-and-drop-hitbox \
  @atlaskit/pragmatic-drag-and-drop-auto-scroll \
  @atlaskit/pragmatic-drag-and-drop-live-region \
  @atlaskit/pragmatic-drag-and-drop-react-drop-indicator   # React only, optional
```

## Item: draggable and drop target with closest edge

```tsx
import { useEffect, useRef, useState } from "react";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/adapter/element-adapter";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/utils/combine";
import { attachClosestEdge, extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";

const ITEM = Symbol("task");   // tag drag data so foreign drags are ignored
type TaskData = { [ITEM]: true; id: string; index: number };
const isTaskData = (d: Record<string | symbol, unknown>): d is TaskData => d[ITEM] === true;

export function TaskRow({ task, index }: { task: Task; index: number }) {
  const rowRef = useRef<HTMLLIElement>(null);
  const handleRef = useRef<HTMLButtonElement>(null);
  const [edge, setEdge] = useState<Edge | null>(null);
  const [dragging, setDragging] = useState(false);

  useEffect(() => {
    const element = rowRef.current!;
    const data: TaskData = { [ITEM]: true, id: task.id, index };
    return combine(
      draggable({
        element,
        dragHandle: handleRef.current!,
        getInitialData: () => data,
        onDragStart: () => setDragging(true),
        onDrop: () => setDragging(false),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) => isTaskData(source.data) && source.data.id !== task.id,
        getData: ({ input }) => attachClosestEdge(data, { element, input, allowedEdges: ["top", "bottom"] }),
        onDrag: ({ self }) => setEdge(extractClosestEdge(self.data)),
        onDragLeave: () => setEdge(null),
        onDrop: () => setEdge(null),
      }),
    );
  }, [task.id, index]);

  return (
    <li ref={rowRef} style={{ position: "relative", opacity: dragging ? 0.4 : 1 }}>
      <button ref={handleRef} aria-label={`Reorder ${task.title}`} className="drag-handle">⋮⋮</button>
      {task.title}
      <MoveMenu task={task} index={index} />
      {edge && <DropIndicator edge={edge} gap="8px" />}
    </li>
  );
}
```

Only render the indicator when `edge` is set; it is cheap that way.

## List: monitor, reorder, auto-scroll, announce

```tsx
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/adapter/element-adapter";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/utils/combine";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/utils/reorder";
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { announce } from "@atlaskit/pragmatic-drag-and-drop-live-region";

useEffect(() => {
  return combine(
    monitorForElements({
      canMonitor: ({ source }) => isTaskData(source.data),
      onDrop: ({ source, location }) => {
        const target = location.current.dropTargets[0];
        if (!target || !isTaskData(source.data) || !isTaskData(target.data)) return;
        const startIndex = source.data.index;
        const finishIndex = getReorderDestinationIndex({
          startIndex,
          indexOfTarget: target.data.index,
          closestEdgeOfTarget: extractClosestEdge(target.data),
          axis: "vertical",
        });
        if (finishIndex === startIndex) return;
        const next = reorder({ list: tasks, startIndex, finishIndex });
        moveItem(source.data.id, finishIndex, next);    // same function the Move menu calls
        announce(`${tasks[startIndex].title} moved to position ${finishIndex + 1} of ${tasks.length}`);
      },
    }),
    autoScrollForElements({ element: scrollContainerRef.current! }),
  );
}, [tasks]);
```

- `getReorderDestinationIndex` converts "dropped on the bottom edge of item 3" into the
  final index, accounting for the item's own removal.
- `autoScrollWindowForElements()` handles window scrolling when the list is not in its
  own scroll container.
- Pragmatic has no keyboard dragging. Atlassian's guidance is a visible action menu per
  item (or a drag-handle menu button when moving is the only action), live-region
  announcements naming the item and old and new positions, and focus returned to the
  trigger after the move.

## Focus after a move

React keeps focus when an item reorders within the same list (same key). When an item
moves to another list it remounts; restore focus manually:

```ts
requestAnimationFrame(() => {
  document.querySelector<HTMLButtonElement>(`[data-menu-for="${id}"]`)?.focus();
});
```

## Other adapters

- Files from the desktop: `dropTargetForExternal` and `monitorForExternal` (4.x ships
  them under `adapter/drop-target-for-external` and `adapter/monitor-for-external`;
  earlier versions used `external/adapter`), with `utils/contains-files` and
  `utils/get-files` to filter and read. Keep a visible file input as the non-drag path.
- Custom drag preview: `setCustomNativeDragPreview` from
  `@atlaskit/pragmatic-drag-and-drop/utils/set-custom-native-drag-preview`.
- Trees: `@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item` gives reorder-above,
  reorder-below and make-child instructions; pair with Indent and Outdent menu items.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc