dnd-kit Accessible Sortable Rows in React
Permalink to "dnd-kit Accessible Sortable Rows in React"dnd-kit is the most widely used drag-and-drop toolkit for React that takes accessibility seriously: it ships a keyboard sensor, a live region for announcements and screen reader instructions. Out of the box, though, it announces items by ID (“Picked up draggable item row-7”), puts drag listeners wherever you spread them — often on the entire row, hijacking every key inside it — and offers no non-drag alternative. A few configuration choices turn it into an accessible sortable table.
This guide belongs to row drag & drop reordering.
Spec reference
Permalink to "Spec reference"- SC 2.1.1 Keyboard (A) — dnd-kit’s
KeyboardSensorprovides Space or Enter to pick up, arrows to move, Space or Enter to drop, Escape to cancel. - SC 4.1.3 Status Messages (AA) — dnd-kit renders a visually hidden
aria-live="assertive"region; you supply the messages. - SC 3.3.2 Labels or Instructions (A) —
screenReaderInstructionsare rendered as hidden text and referenced from each draggable viaaria-describedby. - SC 4.1.2 Name, Role, Value (A) — the handle needs a name; dnd-kit adds
role="button"andaria-roledescription="sortable"to the element withattributesspread on it. - SC 2.5.7 Dragging Movements (AA) — requires move buttons in addition to the keyboard sensor.
When to use — and when not to
Permalink to "When to use — and when not to"Use dnd-kit sortable rows when React tables need reordering and you want keyboard dragging in addition to move buttons. It works with plain tables, TanStack Table and virtualized lists (with care).
Do not spread listeners and attributes on the tr. That makes the whole row a button with aria-roledescription="sortable", overrides its table semantics for some readers, and captures Space and Enter in every cell — including links and checkboxes inside the row. Use a handle.
Annotated code example
Permalink to "Annotated code example"import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
function TaskTable({ tasks, setTasks }) {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), // SC 2.1.1
);
const name = (id) => tasks.find(t => t.id === id)?.name;
const pos = (id) => tasks.findIndex(t => t.id === id) + 1;
const accessibility = {
screenReaderInstructions: { draggable:
'Press Space to pick up. Use Up and Down arrows to move. Press Space to drop or Escape to cancel.' }, // SC 3.3.2
announcements: { // SC 4.1.3 — names, not IDs
onDragStart: ({ active }) => `Picked up ${name(active.id)}, position ${pos(active.id)} of ${tasks.length}.`,
onDragOver: ({ over }) => over ? `Position ${pos(over.id)} of ${tasks.length}.` : 'Not over a position.',
onDragEnd: ({ active, over }) => over ? `Dropped ${name(active.id)} at position ${pos(over.id)} of ${tasks.length}.` : 'Dropped outside the list.',
onDragCancel: ({ active }) => `Cancelled. ${name(active.id)} returned to position ${pos(active.id)}.`,
},
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} accessibility={accessibility}
onDragEnd={({ active, over }) => over && active.id !== over.id &&
setTasks(ts => arrayMove(ts, pos(active.id) - 1, pos(over.id) - 1))}>
<table><caption>Task priority</caption>
<tbody>
<SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
{tasks.map(t => <SortableRow key={t.id} task={t} />)}
</SortableContext>
</tbody>
</table>
</DndContext>
);
}
function SortableRow({ task }) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({ id: task.id });
return (
<tr ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }}>
<td>
{/* Handle only: listeners and attributes here, not on the tr (SC 4.1.2) */}
<button type="button" ref={setActivatorNodeRef} {...attributes} {...listeners}
aria-label={`Reorder ${task.name}`}>⠿</button>
</td>
<th scope="row">{task.name}</th>
<td><MoveButtons task={task} /></td> {/* single-pointer alternative (SC 2.5.7) */}
</tr>
);
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Action | Expected announcement | AT deviations |
|---|---|---|---|
| Tab to handle | — | “Reorder Design review, sortable” + instructions | aria-roledescription replaces “button” — some readers then omit “button” |
| Space | Pick up | “Picked up Design review, position 3 of 12.” | — |
| Down | Move | “Position 4 of 12.” | Rapid presses may skip messages in VoiceOver |
| Space | Drop | “Dropped Design review at position 4 of 12.” | — |
| Escape | Cancel | “Cancelled. Design review returned to position 3.” | — |
Integration context
Permalink to "Integration context"The move buttons are covered in keyboard row reordering with move buttons; the message wording follows announcing drag and drop moves with live regions. If the table is built with TanStack Table, the same handle approach combines with row selection in TanStack Table — keep the selection checkbox and the drag handle in separate cells.
Gotchas
Permalink to "Gotchas"aria-roledescription="sortable". dnd-kit sets it by default. It is reasonable, but some teams find “sortable” confusing; you can override it through useSortable({ attributes: { roleDescription: 'draggable row' } }).
Transforms on table rows. CSS transforms on tr elements behave inconsistently in older Safari; if rows jump, apply the transform to a wrapper or use display: grid for the table body.
Virtualized lists. Keyboard dragging past the rendered window needs autoScroll and items that exist when targeted; test with long lists before shipping.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Wrap dnd-kit in a design-system SortableTable that fixes the handle placement, supplies localized announcements from item names and renders move buttons by default. Product teams then cannot ship the ID-based defaults by accident.
Combining dnd-kit with TanStack Table
Permalink to "Combining dnd-kit with TanStack Table"TanStack Table manages row models; dnd-kit manages drag state. Keep them separate and connect them through stable row IDs:
- Use
getRowId: (row) => row.idinuseReactTableso row IDs survive sorting and filtering. - Pass
table.getRowModel().rows.map(r => r.id)toSortableContext. - Disable reordering while a column sort is active —
table.getState().sorting.length > 0— and say why in the reorder column’s header description. - Keep the selection checkbox from row selection in TanStack Table in its own cell, so Space toggles selection there and picks up only on the handle.
const table = useReactTable({ data, columns, getRowId: r => r.id, getCoreRowModel: getCoreRowModel() });
const sorted = table.getState().sorting.length > 0;
<SortableContext items={table.getRowModel().rows.map(r => r.id)} disabled={sorted}>
When reordering is disabled by sorting, render the handle with aria-disabled="true" and a description such as “Clear sorting to reorder” rather than removing it; users then learn why it does nothing.
FAQ
Permalink to "FAQ"Does dnd-kit satisfy WCAG 2.5.7 on its own?
No. Its keyboard sensor serves keyboard users. 2.5.7 needs a single-pointer alternative, such as move buttons.
Why do announcements say "draggable item" and an ID?
Those are the defaults. Supply your own announcements that look up item names.
Can I use dnd-kit with role="grid" tables?
Yes, but the grid’s arrow-key handler and the keyboard sensor will both want arrows. Disable grid navigation while an item is picked up, and restore it on drop or cancel.
Where should focus be after a keyboard drop?
On the handle of the moved row, which dnd-kit keeps by default when the element is preserved. Verify it after your state update re-renders.
How do I add Page Up and Page Down to the keyboard sensor?
Write a custom coordinateGetter that wraps sortableKeyboardCoordinates and, for Page Up and Page Down, returns coordinates ten items away. Mention the keys in screenReaderInstructions so users know they exist.
Related
Permalink to "Related"- Move buttons for reordering — the required alternative
- aria-grabbed deprecation — why dnd-kit uses pressed handles and live regions
- Live regions in React — React-specific announcement pitfalls