Announcing a Column Reorder Without Drag and Drop

Permalink to "Announcing a Column Reorder Without Drag and Drop"

Column reordering is normally built as a drag interaction, which makes it invisible to keyboard users and impossible for anyone who cannot perform a sustained drag. This page covers the keyboard move mode that replaces it, and — the harder half — the announcement that tells a screen reader user where the column ended up. The failure it prevents is a table whose reading order silently changes with no way to know it happened, which is a WCAG 1.3.2 problem dressed as a product feature.

It is the reorder detail beneath resizable, reorderable and frozen columns, and it depends on the announcement mechanics in screen reader announcement strategies.

Spec reference

Permalink to "Spec reference"

Two success criteria govern this directly. SC 2.1.1 Keyboard (Level A) requires all functionality to be operable through a keyboard interface. SC 2.5.7 Dragging Movements (Level AA, added in WCAG 2.2) requires that any function operated by a dragging movement is also operable by a single pointer without dragging — a click on a menu item, for example.

A third criterion applies to the consequence rather than the control. SC 1.3.2 Meaningful Sequence (Level A) requires that when the sequence of content affects meaning, a correct reading sequence is programmatically determinable. Moving a column changes the reading sequence of every row, so the DOM order must change with the visual order — and the user needs to be told.

There is no ARIA attribute for “this column just moved”. The only mechanism is a status message, which is why the announcement is the design work here rather than the markup.

What must be rewritten after a move Matrix of the four pieces of state that must be updated after a column move, with the symptom produced when each is missed. What must be rewritten after a moveUpdate it toSymptom if missedaria-colindexthe new 1-based position on every cellthe reader reports the wrong column numberaria-sortstays with the column, not the positionthe sort indicator migrates to anothercolumnheaders / id wiringre-derived from column idscells reference the wrong headerSaved layoutpersisted by column idthe layout restores into a different order
A reorder is four updates, and skipping any one produces a distinct bug.

When to use — and when not to

Permalink to "When to use — and when not to"

Provide a keyboard move path whenever columns can be reordered at all. The move-mode pattern described below is the most compatible option because it needs no new roles: the column header button stays a button, and the mode is a state your code manages.

Do not implement reordering as a drag with a keyboard fallback bolted on separately, where the two paths update different code. They drift, and the keyboard path is the one that stops working, because nobody notices. Route both through the same moveColumn(from, to) function so a bug in one is a bug in both.

Skip reordering entirely when the column order carries meaning that the user should not change — a fixed report layout, a regulatory statement. In that case, do not render an inert affordance.

Annotated code example

Permalink to "Annotated code example"
// SC 2.1.1 + 2.5.7: one implementation, reachable from keys and from a menu
let moveMode = null;                      // { colId, from, current } or null

function enterMoveMode(colId) {
  const from = indexOf(colId);
  moveMode = { colId, from, current: from };
  announce(`Move mode. ${labelOf(colId)}, position ${from + 1} of ${columnCount()}. ` +
           `Left and right arrows move, Enter applies, Escape cancels.`);
}

function stepMove(delta) {
  if (!moveMode) return;
  const next = clamp(moveMode.current + delta, 0, columnCount() - 1);
  if (next === moveMode.current) return;
  moveMode.current = next;
  previewAt(next);                        // visual only — NO announcement here
}

function commitMove() {
  const { colId, from, current } = moveMode;
  moveMode = null;
  if (current === from) return;
  moveColumn(from, current);              // the real DOM move, every row
  reindexColumns();                       // rewrite aria-colindex on every cell
  // SC 4.1.3: one polite message, after the DOM has settled
  requestAnimationFrame(() =>
    announce(`${labelOf(colId)} moved to position ${current + 1} of ${columnCount()}`));
}

function cancelMove() {
  previewAt(moveMode.from);
  moveMode = null;
  announce('Order restored');
}
// The DOM move itself — every row, in one pass, preserving cell identity
function moveColumn(from, to) {
  for (const row of table.rows) {
    const cell = row.cells[from];
    const before = row.cells[to + (to > from ? 1 : 0)] ?? null;
    row.insertBefore(cell, before);       // move, do not clone
  }
}

Moving rather than cloning matters. Re-creating cells destroys any element identity the rest of the page depends on — a focused cell, an open editor, an aria-activedescendant reference — and it discards event listeners attached directly to cells.

The keyboard move, step by step Four-step sequence for moving a column with the keyboard: enter move mode, step the column left or right, commit, and announce the destination. The keyboard move, step by stepEnter move modeCtrl+Shift+M, or a "Move column" menu itemAnnounce "Move mode, Amount, position 5 of 7"Step the columnLeft and Right move it one positionSilent while stepping, or the speech queuefloodsCommitEnter applies; Escape restores the original orderThe DOM move happens once, hereAnnounce"Amount moved to position 3 of 7"One polite message after the DOM settles
Announce the destination once, on commit — not on every step.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Context Action Announcement
Ctrl + Shift + M Header button focused Enter move mode Mode, column name, current position and available keys
Left / Right Move mode Step one position Silent — visual preview only
Enter Move mode Commit the move “Amount moved to position 3 of 7”
Escape Move mode Cancel “Order restored”
Tab Move mode Commit and leave, or cancel and leave Whichever you choose, be consistent and announce it

The entry announcement doing double duty as instructions is deliberate. A user who has just entered a mode they cannot see needs to know which keys operate it, and there is no discoverable alternative — no tooltip, no visible hint that a screen reader reads by default.

Reorder announcements that orient the user Comparison of column reorder announcements that give position and total against announcements that only confirm something happened. Reorder announcements that orient the user✓ Orienting"Amount moved to position 3 of 7""Amount is now the first column""Order restored" after Escape✗ Not orienting"Column moved""Reordered"One announcement per intermediate step whilestepping
Position without a total is half an answer.

Integration context

Permalink to "Integration context"

A reorder invalidates more state than most implementations remember, which is what the matrix above enumerates. The two that bite hardest are aria-sort and saved layouts, and both have the same root cause: state keyed by position rather than by column id. Fixing that one thing removes a whole family of bugs, including the sort indicator that appears to jump to a neighbouring column.

If the grid also supports frozen columns, decide what a move into or out of the frozen region means before you build it. The least surprising rule is that the frozen set is defined by count — “the first two columns are frozen” — so moving a column into the first two positions freezes it, and the announcement should say so: “Amount moved to position 1 of 7, now frozen.”

Gotchas

Permalink to "Gotchas"

The announcement fires before the DOM settles. Writing the status text in the same tick as the move means VoiceOver in particular reads a state that has not been painted. One requestAnimationFrame is enough.

Stepping announcements flood the queue. Announcing each intermediate position makes fast stepping impossible. Stay silent during stepping; the visual preview serves sighted users, and the commit announcement serves everyone.

Focus is lost on commit. The header button that started the move is inside the cell being moved. Because the cell is moved rather than re-created, focus survives — but only if the implementation does not re-render the header row from a template on commit. If yours does, restore focus to the button by column id afterwards.

The menu path and the key path diverge. Surface exactly the same operation in a column menu (“Move left”, “Move right”, “Move to first”) so pointer users who cannot drag have a route. Route it through moveColumn so the announcement is identical.

FAQ

Permalink to "FAQ"
Is drag and drop alone ever sufficient for column reordering?

No. WCAG 2.2 SC 2.5.7 Dragging Movements requires that any function operated by dragging is also operable with a single pointer without dragging, and SC 2.1.1 requires a keyboard path. A move mode driven by arrow keys satisfies both at once, because the same commands can be surfaced as menu items for pointer users who cannot drag.

Should each step of a keyboard move be announced?

No. Announce entering move mode, then stay silent while the user steps, then announce the final destination on commit. Announcing each step floods the speech queue and makes fast stepping impossible, because the reader is still describing position four when the user has reached position six.

What should Escape do during a column move?

Restore the original order and say so. A move mode that leaves the column wherever the user happened to stop when they pressed Escape is indistinguishable from a commit, which makes Escape unusable as a safety net. Announce “Order restored” so the outcome is unambiguous.

Permalink to "Related"

Back to Resizable, Reorderable & Frozen Columns