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 KeyboardSensor provides 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) — screenReaderInstructions are rendered as hidden text and referenced from each draggable via aria-describedby.
  • SC 4.1.2 Name, Role, Value (A) — the handle needs a name; dnd-kit adds role="button" and aria-roledescription="sortable" to the element with attributes spread on it.
  • SC 2.5.7 Dragging Movements (AA) — requires move buttons in addition to the keyboard sensor.
What dnd-kit provides and what you add Layers: dnd-kit provides the keyboard sensor, the live region and the instructions element; you add item names in announcements, a dedicated handle with a name, move buttons, and focus handling for move buttons. What dnd-kit provides and what you addLibrary: keyboardsensorSpace or Enter, arrows, Escape — sortableKeyboardCoordinatesLibrary: live regionassertive region rendered by DndContextYou: names inmessagesannouncements and screenReaderInstructions with item namesYou: dedicated handlelisteners on a button, not the row; aria-label with the itemYou: move buttonssingle-pointer alternative required by 2.5.7
The library supplies mechanics; meaning and alternatives are yours.

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.

Listeners on the row versus on a handle Comparison: spreading listeners on the row overrides row semantics and captures keys from controls inside it; a dedicated handle keeps the row a row and scopes drag keys to one button. Listeners on the row versus on a handle✗ Listeners on the trRow exposed as "sortable" buttonSpace in a checkbox cell starts a dragEvery cell becomes part of a controlName: whole row text concatenated✓ Listeners on a handle buttonRow stays a table rowOnly the handle starts a dragCells keep their own controlsName: "Reorder Design review"

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.” —
Default dnd-kit output versus configured Matrix comparing default and configured dnd-kit on announcements, handle name, key capture and single-pointer alternative. Default dnd-kit output versus configuredDefaultConfiguredAnnouncements"Picked up draggable item t7""Picked up Design review, position 3 of 12"Handle nameRow text or nothing"Reorder Design review"Key captureWhole row if spread on trHandle only2.5.7 alternativeNoneMove up and down buttons
Four changes turn the defaults into an accessible implementation.

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:

  1. Use getRowId: (row) => row.id in useReactTable so row IDs survive sorting and filtering.
  2. Pass table.getRowModel().rows.map(r => r.id) to SortableContext.
  3. Disable reordering while a column sort is active — table.getState().sorting.length > 0 — and say why in the reorder column’s header description.
  4. 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.

Permalink to "Related"

← Back to Row Drag & Drop Reordering