Drag-and-drop is the usual way to reorder itinerary stops, priority lists and survey questions in a form — and for keyboard users, many switch and voice-control users, and screen-reader users, it is the only operation on the page they cannot perform at all.

WCAG 2.2’s “Dragging Movements” criterion (2.5.7) requires a single-pointer alternative to dragging, and keyboard operability (2.1.1) requires the function to work from a keyboard. The simplest design satisfies both: explicit Move up and Move down buttons on each row. This page, part of keyboard navigation patterns, builds those buttons with correct focus and announcements, then adds an optional keyboard “grab” mode for power users — on top of the identity model from dynamic field arrays and repeatable groups.


Context and prerequisites

What reordering must preserve and communicate:

  • Identity — rows move as units with their values, errors and state; keys are stable row ids, per stable keys for reorderable field arrays.
  • Focus — after moving, focus stays on the control the user pressed, in the row’s new position, so repeated presses keep moving the same row.
  • Position — each row’s legend states its position (“Stop 2 of 5”) so the new order is perceivable.
  • Announcement — a polite message (“Paris moved to position 2 of 5”) confirms the move without the user having to explore.

Three interaction models, in order of how universally they work:

  1. Move up / Move down buttons — work for keyboard, screen readers, switch access, voice control and touch.
  2. Keyboard grab mode — focus a handle, press Space to pick up, arrows to move, Space to drop, Escape to cancel. Faster for keyboard users; must be announced.
  3. Drag and drop — pointer only; an enhancement layered on the other two.
Reordering models and who they serve Move up and Move down buttons work for keyboard, screen-reader, switch, voice-control and touch users and are the baseline. Keyboard grab mode works for keyboard and screen-reader users when announced, and is an enhancement for speed. Drag and drop works for mouse and some touch users only and must never be the sole method. Model Works for Role Move up / Move down buttons keyboard, SR, switch, voice, touch baseline Keyboard grab (Space, arrows) keyboard, SR (if announced) enhancement Drag and drop pointer users only enhancement

The core pattern: move buttons with focus retention and announcements

import { useLayoutEffect, useRef, useState } from "react";

type Row = { id: string; place: string };

export function Itinerary({ initial, announce }: { initial: Row[]; announce: (m: string) => void }) {
  const [rows, setRows] = useState(initial);
  // Remember which button to refocus after the move re-renders the list.
  const refocus = useRef<{ id: string; dir: "up" | "down" } | null>(null);
  const buttons = useRef(new Map<string, HTMLButtonElement>());

  const move = (id: string, dir: "up" | "down") => {
    setRows((prev) => {
      const i = prev.findIndex((r) => r.id === id);
      const j = dir === "up" ? i - 1 : i + 1;
      if (j < 0 || j >= prev.length) return prev;
      const next = [...prev];
      [next[i], next[j]] = [next[j], next[i]];
      announce(`${prev[i].place} moved to position ${j + 1} of ${prev.length}.`);
      refocus.current = { id, dir };
      return next;
    });
  };

  useLayoutEffect(() => {
    const r = refocus.current;
    if (!r) return;
    refocus.current = null;
    const same = buttons.current.get(`${r.id}:${r.dir}`);
    // At the top or bottom the pressed button becomes disabled; focus its sibling.
    const other = buttons.current.get(`${r.id}:${r.dir === "up" ? "down" : "up"}`);
    (same && !same.disabled ? same : other)?.focus();
  }, [rows]);

  const reg = (key: string) => (el: HTMLButtonElement | null) => {
    if (el) buttons.current.set(key, el); else buttons.current.delete(key);
  };

  return (
    <ol className="itinerary">
      {rows.map((r, i) => (
        <li key={r.id}>
          <fieldset>
            <legend>Stop {i + 1} of {rows.length}: {r.place}</legend>
            {/* …the row's fields… */}
            <button type="button" ref={reg(`${r.id}:up`)} disabled={i === 0} onClick={() => move(r.id, "up")}>
              Move up<span className="visually-hidden"> {r.place}</span>
            </button>
            <button type="button" ref={reg(`${r.id}:down`)} disabled={i === rows.length - 1} onClick={() => move(r.id, "down")}>
              Move down<span className="visually-hidden"> {r.place}</span>
            </button>
          </fieldset>
        </li>
      ))}
    </ol>
  );
}

Step-by-step walkthrough

  1. Give each row Move up and Move down buttons. Real <button type="button"> elements, named with the row (“Move Paris up”), so voice-control users can say them and screen-reader users know which row they affect.
  2. Swap by row id, not by index. State is keyed by id, so the row’s values and errors move with it.
  3. Refocus the pressed button after re-render. Its row has a new position; focusing the same button there lets users press repeatedly to keep moving. At the ends, move focus to the opposite button instead of leaving it on a disabled one.
  4. Update legends with positions. “Stop 2 of 5: Paris” makes the order perceivable to everyone.
  5. Announce each move politely. “Paris moved to position 2 of 5”, through the page announcer from throttling live region announcements — key it by row so rapid presses announce only the final position.
  6. Layer drag-and-drop on top. When a drag library reports (from, to), translate to ids and reuse the same move-and-announce logic.

Why disabled end buttons need care

Disabling “Move up” on the first row is correct — the action is impossible — but if focus is on that button when it becomes disabled (because the user just moved the row to the top), focus is lost to the document body in some browsers, and the user is dropped out of the list. Moving focus to the row’s other move button keeps them in place, and the announcement tells them why. An alternative is to keep the buttons enabled and announce “Paris is already first” on press; either approach avoids losing focus, which is the failure that matters.

Moving a row twice with the keyboard Focus is on Move Paris up in stop three of five. The user presses Enter; Paris swaps with the row above, the list re-renders, and focus returns to Move Paris up now in stop two. The announcer says Paris moved to position 2 of 5. The user presses Enter again; Paris moves to position one, its Move up button becomes disabled, so focus moves to Move Paris down, and the announcer says Paris moved to position 1 of 5. User List Announcer Enter on "Move Paris up" (stop 3) focus stays on Move Paris up (stop 2) "Paris moved to position 2 of 5." Enter again top: Move up disabled → focus Move down "Paris moved to position 1 of 5."

Failure modes and edge cases

1. Drag-and-drop only

A list reorderable only by dragging fails WCAG 2.5.7 and 2.1.1. Buttons are the minimum; a drag library’s built-in keyboard support is an enhancement to test, not a replacement.

2. Focus on the wrong row after re-render

Keying the list by index means the button at the old position keeps focus while the row moves away. Key by row id and refocus the moved row’s button.

3. Announcements on every press in a burst

Pressing Move down five times quickly produces five announcements. Coalesce by row key so only the final position is spoken.

4. Grab mode without instructions

A keyboard grab mode (Space to lift, arrows to move) is invisible to users who do not know it. Announce instructions when the handle receives focus (“Press Space to reorder”), and announce each position change and the drop.

5. Errors after reordering

Server errors for rows are reported by index; translate them with the order that was submitted, per keeping array errors aligned after reorder and delete.

A keyboard grab mode, as an enhancement At rest, the row's handle is a focusable button whose description says press Space to reorder. Pressing Space lifts the row, announcing that Paris is lifted at position 3 of 5 and to use the arrow keys. Arrow keys move it, announcing each new position. Space drops it and announces the final position; Escape cancels and returns it to where it started. Rest Handle button. "Press Space to reorder." Lifted Space. "Paris lifted, position 3 of 5." Moving ↑ / ↓. Each position announced. Drop / cancel Space: drop, announce. Escape: back to start.

Verification checklist


Frequently Asked Questions

Is a "Move to position" select an alternative?

Yes, and it is efficient for long lists: a select or number input per row (“Position: 2”) moves a row directly. It works well alongside Move up/down buttons; announce the result the same way.

Do drag-and-drop libraries handle keyboard access?

Some provide keyboard sensors and announcements (for example dnd-kit). Test them with keyboard and screen readers before relying on them, and keep visible move buttons for switch, voice and touch users who cannot use either drag or keyboard shortcuts.

Should reordering mark the form dirty?

If order is meaningful — itineraries, priorities — yes. If the collection is unordered, reordering should not count as a change; that is the list-versus-set decision in deep equality for dirty detection on nested values.


Related

← Keyboard Navigation Patterns