Building an Accessible Reorderable List in Frontend Apps
Building an Accessible Reorderable List in Frontend Apps
A reorderable list is one of the most useful interactive patterns you can ship, but it needs a keyboard-friendly fallback, clear focus handling, and an alternate single-pointer path to satisfy accessibility requirements. The code below shows how to build it with plain React, so you can adapt the pattern to any frontend stack.
Why this pattern matters
Drag-and-drop is considered a high-risk accessibility pattern because it often works well for mouse users while leaving keyboard and assistive technology users behind. WCAG guidance also says dragging interactions should have an alternative single-pointer mode, and keyboard access is required for full operability.
A good reorderable list solves that by giving users two ways to do the same task: pointer dragging for convenience and buttons or keyboard commands for precision. The pattern becomes more predictable when each item clearly exposes its state, focus position, and move controls.
What we are building
We will build a todo-style list where each item can move up or down, be reordered by drag gestures, and still be fully usable without drag-and-drop. The example uses semantic HTML, roving focus, and aria-live announcements for status changes.
The important part is not the drag effect itself, but the fallback behavior: if a user cannot drag, they still need a complete path to complete the task. That aligns with the WCAG 2.5.7 understanding document, which explicitly recommends adjacent controls for moving items up or down.
Core data model
Start with a simple array of items and keep the source of truth in state. Every reordering operation should produce a new array, because that keeps rendering predictable and makes it easy to sync to a server later.
import React, { useMemo, useRef, useState } from "react";
type Item = {
id: string;
label: string;
};
const initialItems: Item[] = [
{ id: "a", label: "Write outline" },
{ id: "b", label: "Draft examples" },
{ id: "c", label: "Review accessibility" },
{ id: "d", label: "Publish post" },
];
Use helper functions for moving items up, down, or to a target index. Keeping the logic isolated makes the UI code much smaller and easier to test.
function moveItem<T>(list: T[], from: number, to: number) {
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
Accessible item markup
Each row should be a real list item with a visible label and explicit move controls. Do not rely on color or drag handles alone; the user needs controls that are obvious, focusable, and operable from the keyboard.
function ReorderableList() {
const [items, setItems] = useState(initialItems);
const [grabbedId, setGrabbedId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const listRef = useRef<HTMLUListElement | null>(null);
const indexById = useMemo(() => {
return new Map(items.map((item, index) => [item.id, index]));
}, [items]);
function announce(message: string) {
setAnnouncement(message);
}
function moveById(id: string, direction: -1 | 1) {
const from = indexById.get(id);
if (from === undefined) return;
const to = from + direction;
if (to < 0 || to >= items.length) return;
setItems((current) => moveItem(current, from, to));
const label = items[from].label;
announce(`${label} moved to position ${to + 1} of ${items.length}.`);
}
return (
<div>
<p id="reorder-help">
Use the Move up and Move down buttons, or press Space to pick up an item and arrow keys to move it.
</p>
<p aria-live="polite" className="sr-only">
{announcement}
</p>
<ul ref={listRef} aria-describedby="reorder-help">
{items.map((item, index) => {
const grabbed = grabbedId === item.id;
return (
<li
key={item.id}
tabIndex={0}
aria-grabbed={grabbed}
aria-label={`${item.label}, item ${index + 1} of ${items.length}`}
>
<span>{item.label}</span>
<button
type="button"
onClick={() => moveById(item.id, -1)}
disabled={index === 0}
>
Move up
</button>
<button
type="button"
onClick={() => moveById(item.id, 1)}
disabled={index === items.length - 1}
>
Move down
</button>
<button
type="button"
onClick={() => setGrabbedId(grabbed ? null : item.id)}
>
{grabbed ? "Drop" : "Pick up"}
</button>
</li>
);
})}
</ul>
</div>
);
}
This structure follows the general ARIA pattern advice of using clear roles, labels, and predictable interaction states. It also gives each item a direct fallback path through buttons, which is the safest way to satisfy the requirement for a non-drag alternative.
Keyboard interaction model
The keyboard behavior should be simple and discoverable. A good model is: Tab to focus an item, Space to toggle “picked up,” and Arrow Up/Down to move it while grabbed.
Here is the key handler:
function onItemKeyDown(
e: React.KeyboardEvent<HTMLLIElement>,
itemId: string,
index: number
) {
if (e.key === " " || e.key === "Spacebar") {
e.preventDefault();
setGrabbedId((current) => (current === itemId ? null : itemId));
return;
}
if (grabbedId !== itemId) return;
if (e.key === "ArrowUp") {
e.preventDefault();
moveById(itemId, -1);
}
if (e.key === "ArrowDown") {
e.preventDefault();
moveById(itemId, 1);
}
if (e.key === "Escape") {
e.preventDefault();
setGrabbedId(null);
announce("Move cancelled.");
}
}
The important detail is that the same item can be both focusable and movable. That keeps the mental model stable for keyboard users and prevents the interface from feeling like a separate “mode” they cannot escape.
Pointer drag support
If you want drag-and-drop as a convenience feature, keep it additive rather than mandatory. The simplest modern approach is pointer events, because they work across mouse, stylus, and many touch scenarios without depending on HTML’s native drag behavior.
A minimal implementation looks like this:
const dragState = useRef<{
id: string | null;
startY: number;
currentY: number;
}>({ id: null, startY: 0, currentY: 0 });
function onPointerDown(e: React.PointerEvent<HTMLLIElement>, itemId: string) {
dragState.current = {
id: itemId,
startY: e.clientY,
currentY: e.clientY,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: React.PointerEvent<HTMLLIElement>) {
if (!dragState.current.id) return;
dragState.current.currentY = e.clientY;
}
function onPointerUp() {
const draggedId = dragState.current.id;
if (!draggedId) return;
const delta = dragState.current.currentY - dragState.current.startY;
const threshold = 40;
if (delta > threshold) moveById(draggedId, 1);
if (delta < -threshold) moveById(draggedId, -1);
dragState.current = { id: null, startY: 0, currentY: 0 };
}
This is intentionally conservative. For production apps, you would usually add collision detection, better hit targets, and visual feedback while dragging. The key principle is to preserve the non-drag controls so that the feature remains usable even when dragging is awkward or unavailable.
Focus and feedback
Reordering interfaces fail when users lose track of where focus went after a move. After each reorder, keep the moved item focused and announce the new position in an aria-live region.
function announceMove(label: string, position: number, total: number) {
setAnnouncement(`${label} moved to position ${position} of ${total}.`);
}
You should also make focus visible with more than just color if possible. Material’s guidance notes that states should be consistent and accessible, and interaction states should be clear enough to communicate status without ambiguity.
A complete example
Here is a compact version you can paste into a React app and extend:
import React, { useMemo, useRef, useState } from "react";
type Item = { id: string; label: string };
const initialItems: Item[] = [
{ id: "1", label: "Research" },
{ id: "2", label: "Prototype" },
{ id: "3", label: "Test" },
{ id: "4", label: "Ship" },
];
function moveItem<T>(list: T[], from: number, to: number) {
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
export default function ReorderableList() {
const [items, setItems] = useState(initialItems);
const [grabbedId, setGrabbedId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const refs = useRef<Record<string, HTMLLIElement | null>>({});
const indexById = useMemo(
() => new Map(items.map((item, index) => [item.id, index])),
[items]
);
function moveById(id: string, direction: -1 | 1) {
const from = indexById.get(id);
if (from === undefined) return;
const to = from + direction;
if (to < 0 || to >= items.length) return;
setItems((current) => moveItem(current, from, to));
setAnnouncement(`${items[from].label} moved to position ${to + 1} of ${items.length}.`);
requestAnimationFrame(() => refs.current[id]?.focus());
}
function onItemKeyDown(e: React.KeyboardEvent<HTMLLIElement>, id: string) {
if (e.key === " " || e.key === "Spacebar") {
e.preventDefault();
setGrabbedId((current) => (current === id ? null : id));
return;
}
if (grabbedId !== id) return;
if (e.key === "ArrowUp") {
e.preventDefault();
moveById(id, -1);
}
if (e.key === "ArrowDown") {
e.preventDefault();
moveById(id, 1);
}
if (e.key === "Escape") {
e.preventDefault();
setGrabbedId(null);
setAnnouncement("Move cancelled.");
}
}
return (
<section>
<p id="help">
Tab to an item, press Space to pick it up, and use Arrow keys to move it.
</p>
<p aria-live="polite" className="sr-only">
{announcement}
</p>
<ul aria-describedby="help">
{items.map((item, index) => {
const grabbed = grabbedId === item.id;
return (
<li
key={item.id}
ref={(node) => {
refs.current[item.id] = node;
}}
tabIndex={0}
onKeyDown={(e) => onItemKeyDown(e, item.id)}
aria-grabbed={grabbed}
aria-label={`${item.label}, item ${index + 1} of ${items.length}`}
>
<span>{item.label}</span>
<button type="button" onClick={() => moveById(item.id, -1)} disabled={index === 0}>
Move up
</button>
<button type="button" onClick={() => moveById(item.id, 1)} disabled={index === items.length - 1}>
Move down
</button>
<button type="button" onClick={() => setGrabbedId(grabbed ? null : item.id)}>
{grabbed ? "Drop" : "Pick up"}
</button>
</li>
);
})}
</ul>
</section>
);
}
The sample uses a roving focus-friendly approach and a visible control set, which is a strong practical baseline for accessible reordering. It avoids the brittle assumption that drag alone is enough.
Styling the states
Use CSS to make state changes obvious without depending on a pointer-only hover cue. Focus rings, pressed states, and grabbed states should all be visually distinct.
li {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 0.5rem;
align-items: center;
padding: 0.75rem;
border: 1px solid #d0d7de;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
li:focus-visible {
outline: 3px solid #0969da;
outline-offset: 2px;
}
li[aria-grabbed="true"] {
background: #eef6ff;
border-color: #0969da;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
Notice that the visual state and the accessible state tell the same story. That consistency is what keeps the component trustworthy for both sighted and screen reader users.
Testing checklist
Test the component with only a keyboard first. You should be able to focus each item, pick it up, move it, drop it, and recover with Escape without losing your place.
Then test with a mouse or touch device and verify that pointer interactions still work, but are not required. Finally, run a screen reader pass and confirm that the announcements describe the changed position clearly and do not spam the user.
Practical extensions
Once the basic pattern works, you can add persistence, optimistic saves, and server sync. For example, you can debounce a reorder save and submit the final array order after the user finishes moving items, while still keeping the local UI instant.
You can also support multi-select reordering, drag handles, or nested lists, but each added layer raises the accessibility cost. Start with the simplest interaction that works for everyone, then only add richer gestures when they do not remove the keyboard path.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)