Keyboard Row Reordering with Move Buttons
Permalink to "Keyboard Row Reordering with Move Buttons"Drag-and-drop reordering — grabbing a row’s handle and dropping it elsewhere — is the default in priority lists, playlists, kanban-style tables and form builders. It is also unusable for keyboard users, slow for switch and eye-tracking users, and invisible to screen readers. The robust alternative is old-fashioned and effective: buttons that move a row up or down one place, and a command that moves it to a specific position. They work with every input method and every assistive technology, and they give a precise result that drags often do not.
This guide is the foundation of row drag & drop reordering. The drag itself can stay for mouse users; this is what everyone else uses.
Spec reference
Permalink to "Spec reference"- SC 2.5.7 Dragging Movements (AA, WCAG 2.2) — reordering by drag needs a single-pointer alternative. Move buttons are that alternative.
- SC 2.1.1 Keyboard (A) — reordering must be keyboard operable.
- SC 2.4.3 Focus Order (A) — after a move, focus should stay with the row that moved, so repeated moves are possible.
- SC 4.1.3 Status Messages (AA) — the new position is a status message.
- SC 2.5.8 Target Size (Minimum) (AA) — small arrow buttons need 24×24 CSS pixel targets or spacing.
The order itself is conveyed by DOM order (SC 1.3.2 Meaningful Sequence) and, when rows are numbered, by a visible position column or aria-posinset in list-based UIs.
When to use — and when not to
Permalink to "When to use — and when not to"Provide move buttons (or menu items) on every reorderable row. For lists longer than about fifteen items, add “Move to position…”, because moving an item from 40th to 2nd one step at a time takes 38 presses.
Do not rely on a “keyboard drag mode” (press Space to pick up, arrows to move, Space to drop) as the only alternative. It is a good addition for keyboard users, but it does nothing for single-pointer users, who need a clickable control — that is what 2.5.7 asks for.
The misapplication: move buttons that re-render the whole table, so focus lands on the document body after each press. The user then has to find the row again before every move.
Annotated code example
Permalink to "Annotated code example"<table aria-labelledby="tasks-caption">
<caption id="tasks-caption">Task priority (12 tasks)</caption>
<thead><tr><th scope="col">Position</th><th scope="col">Task</th><th scope="col">Reorder</th></tr></thead>
<tbody>
<tr data-id="t7">
<td>6</td> <!-- visible position (SC 1.3.2) -->
<th scope="row">Design review</th>
<td class="reorder">
<!-- Names include the row so they are unique out of context (SC 2.4.6) -->
<button type="button" data-move="up" aria-label="Move Design review up">↑</button>
<button type="button" data-move="down" aria-label="Move Design review down">↓</button>
<button type="button" data-move="to" aria-haspopup="dialog">Move to…</button>
</td>
</tr>
</tbody>
</table>
<p id="reorder-status" role="status" class="visually-hidden"></p>
tbody.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-move]');
if (!btn || btn.dataset.move === 'to') return;
const id = btn.closest('tr').dataset.id;
const dir = btn.dataset.move;
const from = order.indexOf(id);
const to = dir === 'up' ? from - 1 : from + 1;
if (to < 0 || to >= order.length) { // boundary: say so (SC 4.1.3)
status.textContent = dir === 'up' ? 'Already at the top.' : 'Already at the bottom.';
return;
}
order.splice(to, 0, order.splice(from, 1)[0]);
render(order); // keyed render keeps rows stable
// Refocus the same control on the moved row (SC 2.4.3)
tbody.querySelector(`tr[data-id="${id}"] button[data-move="${dir}"]`).focus();
status.textContent = `Moved to position ${to + 1} of ${order.length}.`;
save(order);
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Tab to Move up | “Move Design review up, button” | — |
| Activate | “Moved to position 5 of 12.” Focus remains on the button | VoiceOver may re-read the button name first |
| Activate at the top | “Already at the top.” | — |
| Move to… → choose 2 → OK | Dialog closes; “Moved to position 2 of 12.” Focus on the row’s Move to… button | Dialog close may announce the table caption in JAWS |
| Browse the table afterwards | Rows read in new order with updated position column | — |
Integration context
Permalink to "Integration context"Move buttons usually live in the row’s action menu, following row action menus with the menu button pattern, or as visible controls in a reorder column. For a faster keyboard path, add the pick-up-and-move mode in announcing drag and drop moves with live regions. The Move to dialog follows native dialog versus custom focus traps.
Gotchas
Permalink to "Gotchas"Unkeyed re-renders. Frameworks that render lists by index rebuild every row after a move; focus is lost and the screen reader may re-read the whole table. Key rows by ID.
Sorted tables. If the table is sorted by a column, manual order is meaningless and moves will be undone by the sort. Hide move controls, or explain: “Clear sorting to reorder tasks.”
Saving. If each move saves to the server, a failure must roll the row back and say so; silently restoring the old order looks like the button did nothing.
Framework notes
Permalink to "Framework notes"In React, key rows by ID (key={task.id}) and refocus in a layout effect after the state update, using a ref map from ID to button. In Vue, :key="task.id" plus nextTick(() => btn.focus()). In Angular, trackBy on *ngFor (or track task.id in @for) prevents DOM rebuilds; refocus after change detection with afterNextRender.
const refs = useRef(new Map());
useLayoutEffect(() => {
if (lastMove) refs.current.get(`${lastMove.id}:${lastMove.dir}`)?.focus();
}, [order]);
<button ref={el => refs.current.set(`${t.id}:up`, el)} aria-label={`Move ${t.name} up`}>↑</button>
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide a ReorderControls component that takes the item’s name, position and total, and emits move events; it should own button names, focus restoration hooks and the announcement wording. Pair it with a useReorder helper that handles keyed state and saving, so teams adding drag-and-drop get the alternative for free.
FAQ
Permalink to "FAQ"Do move buttons satisfy WCAG 2.5.7 on their own?
Yes. They provide a single-pointer, non-drag way to achieve the same result. A keyboard-only drag mode does not satisfy 2.5.7, because it is not operable with a pointer.
Should the arrow buttons show text or only icons?
Icons are fine if the accessible name is descriptive and includes the item, and if a tooltip or visible label is available on hover and focus. Text labels are clearer for voice control users, who must speak the name.
Where should focus go after Move to position?
Back to the moved row’s Move to button, in its new place. Users can then continue reading from the row they just moved.
Should the position column be editable instead?
An editable position number is a valid alternative for power users, but it is harder to understand and validate. Offer it in addition to, not instead of, move buttons.
Related
Permalink to "Related"- Announcing drag and drop moves — the keyboard drag mode
- Why not aria-grabbed — what replaced the old attributes
- Row action menus — where move commands often live