# Sortable lists that move only by dragging, or in silence

Source: https://easeweb.dev/learn/sortable-lists
Topics: Drag and drop > Lists can be reordered without dragging, and the new position is announced (WCAG 2.1.1, 2.5.7, 4.1.3)
The fix: Keep the drag, and give every row a way to move that needs no drag: either Move up and Move down buttons, or a Position list with a Move button. After every move, write the item's name and its new position into a status region that was on the page from the start, and keep focus on the control. Keyboard dragging with the arrow keys helps keyboard users, but on its own still leaves out people who use a single pointer.
Test with: keyboard, mouse, touch, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F108 https://www.w3.org/WAI/WCAG22/Techniques/failures/F103

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

After an order, a shop asks one question: "Rank what matters most when you order." Four rows, Price, Delivery speed, Easy returns and Reviews, each start with a grip of six dots, and the most important goes first. With a mouse, you press on Delivery speed, carry it above Price and let go, and the numbers on the left follow. Press Tab and focus goes straight past the list to the Send button: the rows are not in the Tab order, and the grips are drawings, not controls. On a phone, a finger pressed on a row and moved may scroll the page instead of carrying the row. Whoever can't drag sends the order the shop wrote, which is not an answer.

The shop's second build adds Move up and Move down buttons to every row. Now the keyboard can reach them, and pressing Move up on Delivery speed moves it to the top. A sighted user sees the row jump. A screen reader user hears "Move up, button, Delivery speed" again, the same words as before the press, because focus was put back on the same button. Nothing says the item is now first, or that it moved at all. To find out, they leave the button, read the list from the top, and come back.

## Why it fails

The first build gives one way to change the order: a drag, which needs a press held down while the pointer travels. Success Criterion 2.5.7 asks for a way to do the same with single clicks or taps, unless dragging is essential, and it isn't for putting four words in order; with no such way, this is failure F108. The rows are list items with a decorative grip and nothing focusable, so the keyboard can't reorder them either, and 2.1.1 fails too. The grip only looks like a handle: a picture can't be pressed.

The second build passes both, and fails on what happens next. On screen, the press changes the numbers beside the rows: Delivery speed's from 2 to 1, and Price's from 1 to 2. Those numbers report the result of an action without taking focus, as the cart count does in the W3C's own example for Success Criterion 4.1.3, which asks that such a message can be presented to assistive technology through a role or a property, without focus moving to it. None of them is in a live region, which is how this guide reads failure F103. Some reviewers apply 4.1.3 only where a message in words is added to the page; either way, what can be observed is the same. Focus lands back on a button that keeps its name and its description, so a screen reader has nothing new to say, and nothing else on the page tells it where the row went. Moving an element in the DOM is silent, and it takes focus off the button inside it, which is why the script focuses it again.

## Who is affected

The list is answered by the people who can see it whole and drag with ease. Everyone else either can't change the order or can't tell what order it is in now, and a ranking that goes in unchanged looks like a real answer to whoever reads the results.

## What automation and AI miss

Nothing in the markup is wrong: list items need no name, `draggable="true"` is valid HTML, and the Move buttons have names. A scan never drags a row or presses a button, so it never finds that the order can't change, or that a change is silent. EaseWeb's audit reports a list of draggable items with no button or select of their own as a lead to test by hand, and a list whose items can all be moved without dragging, with no live region outside the list or with the list inside one, as a second lead. Asked to make the list accessible, AI tools often add `tabindex="0"` and `role="button"` to the grips, which puts them in the Tab order with nothing for the keyboard to do, or put `aria-live` on the list, which reads the moved row's text and buttons again, with no position.

## Before: rows that move only by dragging

```html
<h2 id="rank-title">Rank what matters most when you order</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">Delivery speed</span>
  </li>
  …
</ol>
```

```js
// The drag: dropping a row puts it where the row under the pointer was.
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

// Puts an item at a position, counting from 0.
function placeItem(item, index) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
}
```

The only way to move a row is to drag it. The grip is hidden from assistive technology and can't take focus, and a list item can't be pressed.

## Before: Move up and Move down that say nothing

```html
<h2 id="rank-title">Rank what matters most when you order</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">Delivery speed</span>
    <button type="button" data-move="-1" aria-describedby="item-speed">Move up</button>
    <button type="button" data-move="1" aria-describedby="item-speed">Move down</button>
  </li>
  …
</ol>
```

```js
// The drag: dropping a row puts it where the row under the pointer was.
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

ranking.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-move]');
  if (!button) return;
  const item = button.closest('li');
  const index = [...ranking.children].indexOf(item) + Number(button.dataset.move);
  if (index >= 0 && index < ranking.children.length) placeItem(item, index, button);
});

// Puts an item at a position, counting from 0, and gives focus back to the control.
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  control?.focus();
}
```

The buttons move the row with a click, a tap or a key, and focus stays with them, but the new position is shown only on screen. A screen reader reads the same button again and nothing else.

## After: Move up and Move down, with each move said

```html
<h2 id="rank-title">Rank what matters most when you order</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">Delivery speed</span>
    <button type="button" data-move="-1" aria-describedby="item-speed">Move up</button>
    <button type="button" data-move="1" aria-describedby="item-speed">Move down</button>
  </li>
  …
</ol>
<p id="rank-status" role="status"></p>
```

```js
// The drag: dropping a row puts it where the row under the pointer was.
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

ranking.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-move]');
  if (!button) return;
  const item = button.closest('li');
  const index = [...ranking.children].indexOf(item) + Number(button.dataset.move);
  if (index >= 0 && index < ranking.children.length) placeItem(item, index, button);
  else rankStatus.textContent = `${itemName(item)} is already ${index < 0 ? 'first' : 'last'}.`;
});

// Puts an item at a position, counting from 0, gives focus back to the control, and says where
// the item went.
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  control?.focus();
  rankStatus.textContent = `${itemName(item)} moved to ${index + 1} of ${others.length + 1}.`;
}
```

Each button moves its row one place with a single click, tap or key press, so the list no longer needs a drag, and voice control users can say "Click Move up". The item's name is read as each button's description, so a screen reader says which row a button moves without changing its visible text. After the move, focus is back on the button and the status region, on the page from the start, says "Delivery speed moved to 1 of 4." The drop of a drag calls the same function, so a move with the mouse is announced in the same words. Press Move up again and the button stays where it is, rather than disappearing from under focus, and the status region says "Delivery speed is already first." This suits short lists: moving the sixth of six items to the top takes five presses.

## After: a Position list on each row

```html
<h2 id="rank-title">Rank what matters most when you order</h2>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <svg class="grip" aria-hidden="true">…</svg>
    <span id="item-speed">Delivery speed</span>
    <label for="place-speed">Position</label>
    <select id="place-speed" aria-describedby="item-speed">
      <option value="0">1</option>
      <option value="1" selected>2</option>
      <option value="2">3</option>
      <option value="3">4</option>
    </select>
    <button type="button" data-place aria-describedby="item-speed">Move</button>
  </li>
  …
</ol>
<p id="rank-status" role="status"></p>
```

```js
// The drag: dropping a row puts it where the row under the pointer was.
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

ranking.addEventListener('click', (event) => {
  const button = event.target.closest('button[data-place]');
  if (!button) return;
  const item = button.closest('li');
  placeItem(item, Number(item.querySelector('select').value), button);
});

// Puts an item at a position, counting from 0, gives focus back to the control, and says where
// the item went.
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  showPositions(); // set every row's list to that row's new position
  control?.focus();
  rankStatus.textContent = `${itemName(item)} moved to ${index + 1} of ${others.length + 1}.`;
}
```

The list chooses the position and the button makes the move, so any row reaches any place in two steps, however long the list is. Keep the separate button: in some browsers the arrow keys change a closed select's value straight away, and a select that moved the row on `change` would move it at every arrow press. After the move, every row's list shows its row's new position, focus is back on the Move button, and the status region says where the item went, as with the buttons.

## After: keyboard dragging with the arrow keys

```html
<h2 id="rank-title">Rank what matters most when you order</h2>
<p id="rank-help">To move an item, press Space to pick it up, then the up and down arrow keys,
  then Space to drop it. Escape puts it back.</p>
<ol id="ranking" aria-labelledby="rank-title">
  <li draggable="true" data-item="speed">
    <button type="button" class="grip" aria-describedby="rank-help">
      <svg aria-hidden="true">…</svg>
      <span id="item-speed">Delivery speed</span>
    </button>
  </li>
  …
</ol>
<p id="rank-status" role="status"></p>
```

```js
// The drag: dropping a row puts it where the row under the pointer was.
ranking.addEventListener('drop', (event) => { … placeItem(dragged, index); });

let lifted = null; // the item picked up from the keyboard, and where it was
ranking.addEventListener('keydown', (event) => {
  const button = event.target.closest('button.grip');
  if (!button) return;
  const item = button.closest('li');
  const index = [...ranking.children].indexOf(item);
  const count = ranking.children.length;
  if (lifted?.item !== item) lifted = null; // focus left the row it held
  if (event.key === ' ' || event.key === 'Enter') {
    event.preventDefault();
    lifted = lifted ? null : { item, from: index };
    rankStatus.textContent = lifted
      ? `${itemName(item)}, picked up, ${index + 1} of ${count}.`
      : `${itemName(item)} dropped at ${index + 1} of ${count}.`;
  } else if (lifted && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
    event.preventDefault();
    const to = index + (event.key === 'ArrowUp' ? -1 : 1);
    if (to >= 0 && to < count) placeItem(item, to, button);
    else rankStatus.textContent = `${itemName(item)} is already ${to < 0 ? 'first' : 'last'}.`;
  } else if (lifted && event.key === 'Escape') {
    const from = lifted.from;
    lifted = null;
    placeItem(item, from, button);
    rankStatus.textContent = `${itemName(item)} put back at ${from + 1} of ${count}.`;
  }
});

// Puts an item at a position, counting from 0, gives focus back to the control, and says where
// the item went.
function placeItem(item, index, control) {
  const others = [...ranking.children].filter((li) => li !== item);
  ranking.insertBefore(item, others[index] ?? null);
  control?.focus();
  rankStatus.textContent = `${itemName(item)} moved to ${index + 1} of ${others.length + 1}.`;
}
```

Space on a row picks it up, the up and down arrow keys carry it, Space drops it and Escape puts it back, and each step is announced in the status region. This passes 2.1.1 and 4.1.3 for keyboard and screen reader users, and it is what many sortable libraries offer from the keyboard. It does not pass 2.5.7 on its own: someone on a touch screen, with a head pointer or a single switch still has to drag, and voice control users have to say every arrow key. Many screen readers also keep the arrow keys for reading unless the user switches modes, so the picked-up row may not move. Add it to one of the other fixes, not instead of them.

## Which option to use

Move up and Move down and the Position list both pass on their own, for keyboard, pointer and screen reader users. The buttons take one press per place and are the easiest to find and to say, so they suit short lists, up to five or six items. The Position list takes two steps for any move and stays the same however long the list is; choose it for longer lists, or where items often jump several places. Keyboard dragging is worth adding when keyboard users ask for a faster way, but only beside one of the others.

## Implementation decisions

Keep the drag. It is fast for people who can do it, and 2.5.7 asks for an alternative, not a replacement. Put the drag on the row and the controls inside it, so a press on a button never starts a drag, and run the buttons on click, not on press, so a slip can be taken back.

**Focus after the move.** Moving an element in the DOM takes focus off the control inside it, so the script focuses it again. A framework that updates the list moves the row, or creates it afresh, and either way the control loses focus; focus it after the list has updated. Don't disable, hide or remove a button while it has focus, for example Move up when its item reaches the top: in Chrome, focus falls to the page and the person loses their place in the list. Keep the button and let it say "Delivery speed is already first."; if the design dims it, add `aria-disabled="true"` so it is read as unavailable and stays in the Tab order.

**What the message says.** Name the item and its new position, in the numbers the list shows: "Delivery speed moved to 1 of 4." Write it after focus has moved back, so the screen reader reads the button and then the message, and use `role="status"`, which waits for it to finish, not `role="alert"`. Don't read the whole new order, and never make the list itself a live region: a screen reader then reads the moved row's text and buttons again, and with `aria-atomic="true"` every row, without saying where the item went.

**Grids and long lists.** In a grid of photos that wraps, up and down mean earlier and later, so label the buttons "Move earlier" and "Move later", or offer the Position list. In a long list, Move to top and Move to bottom buttons save many presses.

**Saving the order.** If each move is saved at once, a move the server refuses must be said too, with the item put back: "Delivery speed could not be moved. It is still 2 of 4." If the order is sent with a form, keep the fields it sends in the same order as the rows.

**Drag libraries.** Many sortable libraries offer keyboard dragging and a live region of their own. Check the words they announce, which may name items by an id, and add buttons or a Position list for 2.5.7. Use either the library's region or your own for a move, not both, or every move is said twice.

Moving an item between lists or columns, rather than within one list, is covered in [drag-and-drop boards that only a mouse can use](/learn/drag-and-drop), and announcing such a move in [task boards that say nothing when a card moves](/learn/move-announcements).

## Verify the fix

1. Put the mouse aside. Press Tab and confirm you reach a control on every row, in reading order, and that each one moves its row from the keyboard.
2. Using only clicks or taps, move one item to the top of the list and another to the bottom. No step may need the pointer to move while pressed.
3. With a screen reader running, move an item with each control the list offers, and by dragging if you can: each move is announced once, with the item's name and its new position, focus stays on the control, and the rest of the list is not read.
4. At the top of the list, press Move up (or choose position 1 for the first item): focus stays on the control and something is said, and nothing disappears from under focus. Do the same at the bottom.
5. On a touch screen, reorder the list with taps only. With voice control, move an item by saying a control's visible text.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the list component or its drag library changes.

## Limits

This is a starting pattern, not a guarantee for every application. Nested lists, trees whose items can change level and lists that load more items as they scroll have their own needs, and are not covered here. Sortable libraries differ in how they handle focus, keys and announcements: test the complete journey with the assistive technology your users use.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
