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.

Moving a row two places up Chain: focus on row 6's Move up button; activate; the row becomes row 5 and focus stays on its Move up button; activate again; it becomes row 4; each step announces the new position. Moving a row two places upRow 6 focused"Move Design reviewup, button"Row 5"Moved to position 5of 12"Row 4"Moved to position 4of 12"Doneorder savedactivateactivate againEscape or Tab
Focus travels with the row so the user can press the same button again.

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.

Re-render that loses focus versus focus that follows the row Comparison: re-rendering the list sends focus to the body after each move; keeping a stable key and refocusing the same button lets users press repeatedly. Re-render that loses focus versus focus that follows the row✗ Focus lost after each moveFind the row again every timeNo announcement of the new positionBoundary press does nothing✓ Focus stays on the moved row's buttonPress the same button again"Moved to position 4 of 12""Already at the top" or button unavailable

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 —
Move to position dialog Flow of the Move to position dialog: open from the row, the spinbutton or select shows the current position, the user types a new one, confirms, focus returns to the row's button and the new position is announced. Move to position dialogOpen Move to…dialog "Move Design review"focus on position fieldPosition fieldnumber input, 1 to 12, current value 6label: New positionConfirmEnter or Move buttonvalidation for out-of-range valuesClose and refocusfocus to the row's Move to… buttonrow now at position 2Announce"Moved to position 2 of 12."status region

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.

Permalink to "Related"

← Back to Row Drag & Drop Reordering