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:
- Move up / Move down buttons — work for keyboard, screen readers, switch access, voice control and touch.
- 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.
- Drag and drop — pointer only; an enhancement layered on the other two.
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
- 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. - Swap by row id, not by index. State is keyed by id, so the row’s values and errors move with it.
- 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.
- Update legends with positions. “Stop 2 of 5: Paris” makes the order perceivable to everyone.
- 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.
- 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.
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.
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.