Accessible drag-and-drop requires keyboard support (Space/Enter to grab, Arrow keys to move, Escape to cancel) alongside mouse/touch support.
Think of it like a physical conveyor belt — a sighted user can grab a box and move it, but a keyboard user needs to be told "box A is now in position 3" and be able to announce each move operation.
System design context: Accessible drag-and-drop is one of the hardest patterns to get right because:
1import { useState, useRef, useCallback } from "react";23interface DraggableItem {4 id: string;5 label: string;6}78function AccessibleList({ items, onReorder }: {9 items: DraggableItem[];10 onReorder: (newItems: DraggableItem[]) => void;11}) {12 const [focusedIndex, setFocusedIndex] = useState(-1);13 const [isGrabbed, setIsGrabbed] = useState(false);14 const [announcement, setAnnouncement] = useState("");15 const itemRefs = useRef<(HTMLLIElement | null)[]>([]);1617 // Announce position changes to screen readers18 useEffect(() => {19 if (isGrabbed && focusedIndex >= 0) {20 setAnnouncement(21 `${items[focusedIndex].label} grabbed. ` +22 `Position ${focusedIndex + 1} of ${items.length}. ` +23 `Use arrow keys to move, Escape to cancel.`24 );25 }26 }, [isGrabbed, focusedIndex, items]);2728 const handleKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {29 if (!isGrabbed) {30 // Not grabbed: Enter/Space to grab31 if (e.key === "Enter" || e.key === " ") {32 e.preventDefault();33 setIsGrabbed(true);34 setFocusedIndex(index);35 return;36 }37 // Arrow keys move focus (not the item)38 if (e.key === "ArrowDown" && index < items.length - 1) {39 e.preventDefault();40 setFocusedIndex(index + 1);41 itemRefs.current[index + 1]?.focus();42 }43 if (e.key === "ArrowUp" && index > 0) {44 e.preventDefault();45 setFocusedIndex(index - 1);46 itemRefs.current[index - 1]?.focus();47 }48 return;49 }5051 // Grabbed: Arrow keys reorder52 switch (e.key) {53 case "ArrowUp":54 e.preventDefault();55 if (index > 0) {56 const newItems = [...items];57 [newItems[index], newItems[index - 1]] = [newItems[index - 1], newItems[index]];58 onReorder(newItems);59 setFocusedIndex(index - 1);60 itemRefs.current[index - 1]?.focus();61 setAnnouncement(`Moved to position ${index} of ${items.length}`);62 }63 break;64 case "ArrowDown":65 e.preventDefault();66 if (index < items.length - 1) {67 const newItems = [...items];68 [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];69 onReorder(newItems);70 setFocusedIndex(index + 1);71 itemRefs.current[index + 1]?.focus();72 setAnnouncement(`Moved to position ${index + 2} of ${items.length}`);73 }74 break;75 case "Home":76 e.preventDefault();77 if (index > 0) {78 const newItems = [items[index], ...items.filter((_, i) => i !== index)];79 onReorder(newItems);80 setFocusedIndex(0);81 itemRefs.current[0]?.focus();82 setAnnouncement(`Moved to position 1 of ${items.length}`);83 }84 break;85 case "End":86 e.preventDefault();87 if (index < items.length - 1) {88 const newItems = [...items.filter((_, i) => i !== index), items[index]];89 onReorder(newItems);90 setFocusedIndex(newItems.length - 1);91 itemRefs.current[newItems.length - 1]?.focus();92 setAnnouncement(`Moved to position ${newItems.length} of ${newItems.length}`);93 }94 break;95 case "Escape":96 setIsGrabbed(false);97 setAnnouncement(`${items[index].label} dropped.`);98 break;99 case "Enter":100 case " ":101 // Confirm placement102 e.preventDefault();103 setIsGrabbed(false);104 setAnnouncement(`${items[index].label} placed at position ${index + 1}`);105 break;106 }107 }, [isGrabbed, items, onReorder]);108109 return (110 <div>111 {/* Live region for announcements */}112 <div aria-live="assertive" className="visually-hidden">113 {announcement}114 </div>115 <ul role="listbox" aria-label="Drag and drop items">116 {items.map((item, i) => (117 <li118 key={item.id}119 ref={el => itemRefs.current[i] = el}120 role="option"121 tabIndex={0}122 aria-roledescription="draggable item"123 aria-grabbed={isGrabbed && focusedIndex === i}124 aria-selected={focusedIndex === i}125 onKeyDown={(e) => handleKeyDown(e, i)}126 >127 {item.label}128 </li>129 ))}130 </ul>131 </div>132 );133}
Production pitfalls and fixes:
aria-live="assertive" region for drag-and-drop.aria-grabbed — screen readers need to know which item is currently "held."Scaling pattern: For very large lists (1000+ items), combine keyboard-accessible drag-and-drop with virtual scrolling (e.g., react-window). The virtualized list must maintain focus management across virtual boundaries.
Monitoring: Track how many users complete drag-and-drop operations vs. cancel. High cancel rates may indicate poor discoverability of the keyboard pattern.