Building Accessible Drag-and-Drop Interfaces

Implement reorderable lists and drop zones in React using pointer events, understand the native HTML Drag and Drop API and its quirks, provide full keyboard accessibility, support touch input, and know when to reach for a dedicated drag-and-drop library.

Drag and drop feels simple when you drag a file into an email — you grab it, move it, drop it — but implementing it well in a web application is one of the more deceptively hard pieces of interactive UI. The native HTML Drag and Drop API exists, but it is optimized for OS-level file drops and inter-window drags, not for reordering a to-do list smoothly with an animated placeholder. Most production React applications instead build drag interactions on top of low-level pointer events, layering in accessibility and touch support by hand or via a library. This lesson builds a fully working, keyboard-accessible, touch-friendly reorderable list from first principles so you understand exactly what a library like dnd-kit or Pragmatic drag-and-drop is doing under the hood.

Pointer events: one API for mouse, pen, and touch

Before the Pointer Events specification, you had to write separate handlers for mouse events (mousedown, mousemove, mouseup) and touch events (touchstart, touchmove, touchend), each with slightly different coordinate systems and multi-touch semantics. Pointer events unify these into one model: pointerdown, pointermove, pointerup, pointercancel, all carrying a pointerType field ('mouse', 'touch', or 'pen') and a pointerId that lets you track a specific finger or cursor across a gesture.

The key trick that makes pointer-based dragging reliable is setPointerCapture. Calling element.setPointerCapture(event.pointerId) redirects all subsequent pointer events for that pointerId to the element that captured it, even if the pointer physically moves outside the element's bounds. Without capture, dragging quickly can cause pointermove events to fire on whatever element happens to be under the cursor at that instant, breaking your drag logic. With capture, the dragged element keeps receiving events until you release capture or the user lifts their finger.

Pointer events also solve the 'ghost click' and 300ms tap-delay problems that plagued early touch implementations, and because they fire for mouse, pen, and touch uniformly, a single set of handlers gives you drag support across all input types without branching on device.

• onPointerDown starts the drag and records the initial pointer position • onPointerMove updates the dragged element's visual offset • onPointerUp (or onPointerCancel) commits or cancels the drag • setPointerCapture ensures move/up events keep targeting the drag handle even off-element • touch-action: none on the drag handle prevents the browser's default scroll/zoom gestures from fighting the drag

The native HTML Drag and Drop API — and why most apps avoid it

HTML also defines a native drag-and-drop model: set draggable={true} on an element, listen for dragstart, dragover, drop, and dragend, and stash data in event.dataTransfer. This API is genuinely useful for one thing pointer events cannot do at all — dragging content between windows, into the OS file system, or accepting files dropped from the OS into the browser (ondrop with event.dataTransfer.files).

For in-page reordering, though, the native API has real drawbacks. The drag image is a browser-rendered snapshot that is hard to style consistently across browsers, there is no touch support at all (mobile Safari and Chrome do not fire native drag events for touch-initiated drags), you cannot easily animate the placeholder position during the drag, and dragover must call preventDefault() on every single fire or the drop event never fires — a classic source of 'my drop zone doesn't work' bugs.

The practical rule: use the native API when you need cross-boundary drags (into the OS, into an iframe, into another app) or File drops from the desktop. Use pointer-event-based custom logic — or a library built on it — for in-app list reordering, kanban boards, and sortable grids, because you get full control over animation, touch support, and accessibility.

Drop zones: hit-testing and visual feedback

A drop zone in a pointer-based implementation is not a special DOM API — it is a region whose bounding rectangle you compare against the dragged pointer's current coordinates on every pointermove. The common pattern is to measure each list item's getBoundingClientRect() when the drag starts (and cache it, since layout reads during a drag are expensive), then on each move compute which item's vertical midpoint the pointer has crossed to decide the insertion index.

Visual feedback matters more than the mechanics: users need to see a gap open up where the item will land, the dragged item should be visually lifted (a subtle scale and shadow), and other items should slide smoothly out of the way. This is where CSS transitions on transform (covered in the Animations lesson) combine directly with drag logic — you update a translateY on each sibling as the target index changes, and let the transition handle the smooth slide.

• Cache bounding rects once at drag start, not on every move, to avoid layout thrashing • Compare the pointer's Y coordinate to each item's vertical midpoint to compute the target index • Apply a translateY offset to siblings that need to shift, transitioned via CSS • Use aria-live or a visually hidden status region to announce index changes to screen reader users

Keyboard accessibility for reordering

A drag-and-drop interaction that only works with a mouse or finger locks out keyboard and switch-device users entirely. The accessible pattern, used by libraries like dnd-kit, is to make the drag handle a focusable button (tabIndex={0}, role of button or an aria-roledescription of 'sortable') that responds to a 'grab' key — typically Space or Enter — to enter a reordering mode, then Arrow Up / Arrow Down to move the item one position at a time, and Space or Enter again to drop, with Escape to cancel and restore the original position.

Every state change in this keyboard flow must be announced via an ARIA live region, since there is no visual drag to watch. A common approach is a visually-hidden <div aria-live='assertive'> whose text content is updated to messages like 'Picked up item 2 of 5' and 'Item moved to position 3 of 5, press Escape to cancel'.

Touch support and the ergonomics of mobile dragging

On touch devices, a naive drag implementation fights the browser's native scroll gesture: as soon as the user's finger moves, the page scrolls instead of the item dragging. The fix has two parts. First, set the CSS property touch-action: none on the drag handle (not the whole item, so the rest of the list remains scrollable when not touching a handle) — this tells the browser not to interpret touch moves on that element as scroll gestures. Second, many production implementations add a short press-and-hold delay (150–200ms) before a touch pointerdown is treated as the start of a drag, so that a quick tap-and-scroll gesture is not mistaken for a drag start.

Auto-scroll is the other touch-specific concern: when dragging near the top or bottom edge of a scrollable container on a small screen, the container should automatically scroll to reveal more content, typically implemented with a requestAnimationFrame loop that nudges scrollTop while the pointer sits in an edge 'hot zone'.

When to reach for a library

Hand-rolling drag and drop is an excellent learning exercise and is entirely reasonable for a single simple reorderable list. But production apps with multiple drop zones, nested sortable containers (like a kanban board with cards that move between columns), virtualization, or strict cross-browser touch requirements almost always adopt a library, because the edge cases multiply fast: auto-scroll during drag, collision detection between overlapping drop targets, multi-select drag, RTL layouts, and screen-reader announcements all need to compose correctly together.

The two dominant modern choices are dnd-kit — a React-first, hook-based, pointer-event-driven library with built-in keyboard sensors and no external dependencies — and Pragmatic drag-and-drop from Atlassian, which is framework-agnostic, extremely lightweight, and uses the native HTML5 drag API under the hood while layering in accessibility. react-beautiful-dnd, once the default choice, is now in maintenance mode and its own docs point new projects at dnd-kit. Knowing the underlying pointer-event and accessibility model — as built in this lesson — is what lets you configure and debug any of these libraries confidently rather than treating them as a black box.

Core pointer-drag handler shape

setPointerCapture must be called on pointerdown, on the element that will receive the move/up events Always pair pointerup handling with pointercancel — a browser gesture (like an incoming phone call) can cancel a touch mid-drag Keep drag state in a ref, not state, when its updates shouldn't trigger re-renders on every pixel of movement — mirror only the derived visual value (like target index) into state

function useDraggable(onDrop: (fromIndex: number, toIndex: number) => void) {
  const dragState = useRef<{ index: number; startY: number } | null>(null);

  const onPointerDown = (index: number) => (e: React.PointerEvent) => {
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
    dragState.current = { index, startY: e.clientY };
  };

  const onPointerMove = (e: React.PointerEvent) => {
    if (!dragState.current) return;
    // compute offset, update visual transform, recompute target index
  };

  const onPointerUp = (e: React.PointerEvent) => {
    if (!dragState.current) return;
    // commit the reorder via onDrop(fromIndex, toIndex), then clear state
    dragState.current = null;
  };

  return { onPointerDown, onPointerMove, onPointerUp };
}

Code Examples

This component supports both pointer-based dragging (mouse, touch, and pen, unified by the Pointer Events API and setPointerCapture) and a fully separate keyboard interaction path, with both routes sharing the same reorder() helper and announcing state changes through an aria-live region. Bounding rectangles are captured once at drag start into a ref to avoid expensive layout reads on every pointermove.

(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);

Common mistake: Forgetting setPointerCapture causes pointermove/pointerup to stop firing on the drag handle once the pointer moves fast enough to leave its bounds — the drag appears to 'drop' the item randomly mid-gesture.

This is the one place the native Drag and Drop API is the right tool: accepting files dragged in from the operating system's file explorer. event.dataTransfer.files gives you a FileList exactly as if the user had used an <input type='file'>.

e.preventDefault(); // REQUIRED or drop never fires Common mistake: Omitting preventDefault() in onDragOver: the browser's default action for dragover is to reject the drop, so the drop event never fires at all and the zone silently does nothing.

import { useRef, useState, useCallback, useId } from "react";

interface Item {
  id: string;
  label: string;
}

interface SortableListProps {
  items: Item[];
  onReorder: (items: Item[]) => void;
}

function reorder<T>(list: T[], from: number, to: number): T[] {
  const copy = list.slice();
  const [moved] = copy.splice(from, 1);
  copy.splice(to, 0, moved);
  return copy;
}

export function SortableList({ items, onReorder }: SortableListProps) {
  const [dragIndex, setDragIndex] = useState<number | null>(null);
  const [overIndex, setOverIndex] = useState<number | null>(null);
  const [keyboardIndex, setKeyboardIndex] = useState<number | null>(null);
  const [announcement, setAnnouncement] = useState("");
  const rectsRef = useRef<DOMRect[]>([]);
  const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
  const listId = useId();

  const startPointerDrag = useCallback(
    (index: number) => (e: React.PointerEvent) => {
      (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
      rectsRef.current = itemRefs.current.map(
        (el) => el!.getBoundingClientRect()
      );
      setDragIndex(index);
      setOverIndex(index);
      setAnnouncement(`Picked up item 
…
function FileDropZone({ onFiles }: { onFiles: (files: FileList) => void }) {
  const [isOver, setIsOver] = useState(false);

  return (
    <div
      onDragOver={(e) => {
        e.preventDefault(); // REQUIRED or drop never fires
        setIsOver(true);
      }}
      onDragLeave={() => setIsOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setIsOver(false);
        if (e.dataTransfer.files.length > 0) {
          onFiles(e.dataTransfer.files);
        }
      }}
      style={{
        border: isOver ? "2px dashed var(--accent)" : "2px dashed var(--border)",
        borderRadius: 8,
        padding: 32,
        textAlign: "center",
        background: isOver ? "var(--surface-hover)" : "transparent",
      }}
    >
      Drag files here, or click to browse
    </div>
  );
}

Pointer-drag lifecycle vs. keyboard-drag lifecycle

Two independent input paths, one shared commit function — this is what makes the component accessible without a second implementation.

POINTER PATH                          KEYBOARD PATH
  ─────────────                         ─────────────
  pointerdown on handle                 focus handle, press Space/Enter
        │  setPointerCapture(id)              │  enter "grabbed" mode
        ▼                                     ▼
  pointermove ×N                        ArrowDown / ArrowUp
        │  compute target index via           │  move one slot, reorder
        │  cached bounding rects              │  immediately, announce
        ▼                                     ▼
  pointerup / pointercancel             Space/Enter (drop) or Escape (cancel)
        │  commit reorder(from,to)            │  exit "grabbed" mode
        ▼                                     ▼
   onReorder(newList)  ───────────────►  onReorder(newList)
                     both paths converge
                     on the same state update

Important Rules

Prefer pointer events over separate mouse/touch handlers for any custom drag implementation.

Always call setPointerCapture in the pointerdown handler on the element that should keep receiving events.

Handle pointercancel identically to pointerup (as an abort/cleanup), since gestures can be cancelled by the OS mid-drag.

Set touch-action: none only on the drag handle, never on the whole scrollable list.

Every pointer-driven drag interaction needs a fully separate, fully functional keyboard path — it is not optional.

Announce drag state changes via an aria-live region; a purely visual reorder is invisible to screen reader users.

Reserve the native HTML5 Drag and Drop API for OS/file/cross-window drags, not in-app list reordering.

Cache layout measurements (getBoundingClientRect) once per drag gesture, not on every pointermove.

Kanban boards and task lists

Project management tools like Trello-style kanban boards are the canonical drag-and-drop use case: cards drag within a column (reordering) and across columns (re-parenting into a different status list) simultaneously. Production implementations track both the source and destination container ids plus the target index, use auto-scroll near column edges on smaller screens, and virtualize long columns — which is exactly why most teams adopt dnd-kit or Pragmatic drag-and-drop rather than maintaining hand-rolled pointer logic once the interaction grows past a single flat list.

Using only onMouseDown/onMouseMove/onMouseUp and expecting it to also work on mobile.

Use onPointerDown/onPointerMove/onPointerUp, which fire uniformly for mouse, touch, and pen.

Mouse events never fire from touch input on modern browsers in the way developers often assume; pointer events are the unified, standards-based replacement.

Recomputing every list item's getBoundingClientRect() inside the pointermove handler.

Capture all bounding rects once in a ref when the drag starts, and reuse them for the duration of the gesture.

getBoundingClientRect() forces a synchronous layout recalculation; calling it dozens of times per second during a drag causes visible jank.

Shipping a mouse/touch-only drag-and-drop reorder feature with no keyboard equivalent.

Implement a parallel keyboard interaction (grab, arrow-move, drop, escape-cancel) that produces identical state changes.

Keyboard-only and switch-device users cannot perform a pointer drag at all; without a keyboard path the feature is entirely inaccessible to them, which in many jurisdictions is also a legal compliance issue (WCAG 2.1.1).

Why is setPointerCapture essential in a custom pointer-based drag implementation? A. It prevents the default browser context menu from appearing during a drag B. It ensures pointermove and pointerup keep firing on the capturing element even after the pointer physically leaves its bounds C. It automatically animates the dragged element back to its original position D. It is required before any onPointerDown handler will fire at all

B. It ensures pointermove and pointerup keep firing on the capturing element even after the pointer physically leaves its bounds — Without capture, fast pointer movement can cause move/up events to be dispatched to whatever element the pointer is currently over, breaking drag tracking; setPointerCapture redirects all subsequent events for that pointerId to the capturing element.

When is the native HTML5 Drag and Drop API (draggable, dragstart, dataTransfer) the right choice over custom pointer-event logic? A. For reordering items within a single in-app list B. For accepting files dropped in from the operating system, or dragging content across windows/iframes C. For any drag interaction that needs to work on mobile Safari D. It should always be preferred because it requires less code

B. For accepting files dropped in from the operating system, or dragging content across windows/iframes — The native API's unique strength is crossing OS/window/iframe boundaries and receiving OS files via event.dataTransfer.files; it lacks touch support and fine-grained animation control, making custom pointer logic the better fit for in-app reordering.

What must accompany a pointer-driven drag-and-drop reorder feature to make it accessible? A. A CSS-only fallback using the :hover pseudo-class B. A fully independent keyboard interaction path (grab, move, drop, cancel) with aria-live announcements C. Disabling the feature entirely for keyboard users and providing a settings toggle instead D. A tooltip explaining how to use a mouse to drag items

B. A fully independent keyboard interaction path (grab, move, drop, cancel) with aria-live announcements — Pointer/touch dragging alone excludes keyboard and switch-device users; a parallel keyboard flow that produces the same state changes, combined with aria-live announcements of what changed, is required for the feature to be usable by everyone.

Extend the SortableList example to support dragging an item into a different list (two SortableList instances sharing a common onReorder that can move items across lists), while preserving the keyboard path so an item can also be moved between lists with a keyboard-only interaction (for example, Shift+ArrowRight to send the focused item to the next list).

Lift the two lists' state into a common parent, pass a containerId prop to each SortableList, and change onReorder's signature to onMove(itemId, fromContainerId, toContainerId, toIndex). In onPointerMove, in addition to computing overIndex within the current rects, test document.elementsFromPoint(x, y) or compare against each container's outer bounding rect to detect a container switch, and update a separate overContainerId state value that the drop handler reads.

Track a shared 'active container id' alongside the active index, and change your hit-testing to also compare the pointer's X coordinate against each list's bounding rect to decide which list the pointer is currently over.