Resizable, Reorderable & Frozen Columns
Permalink to "Resizable, Reorderable & Frozen Columns"Column manipulation is the point where a data grid stops being a document and starts being an application. Resizing, reordering and freezing columns are three separate operations with three different accessibility consequences: resizing changes only geometry, reordering changes the reading order that every screen reader depends on, and freezing changes what is painted without changing anything semantic. Getting them wrong strands keyboard users at a drag handle they cannot reach, or silently rearranges a table under a screen reader user who has no way to know it happened.
This page is for frontend engineers and design system maintainers building column controls into a grid that already has semantic table construction and, usually, sortable and filterable columns. The failure mode it prevents is the most common one in enterprise grids: a column toolbar that is fully functional with a mouse and completely inoperable without one.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | Why it applies here |
|---|---|---|
| 1.3.1 Info and Relationships | A | A frozen or reordered column must keep its header-to-cell relationship intact |
| 1.3.2 Meaningful Sequence | A | Reordering changes the DOM order, which is the sequence assistive technology reads |
| 2.1.1 Keyboard | A | Every resize, move and freeze gesture needs a keyboard equivalent |
| 2.4.7 Focus Visible | AA | Resize grips are thin targets; their focus indicator must still be visible |
| 2.5.7 Dragging Movements | AA (2.2) | A drag-to-resize or drag-to-reorder needs a single-pointer alternative |
| 4.1.2 Name, Role, Value | A | The resize grip needs a role and a current value, not just a cursor change |
SC 2.5.7 Dragging Movements is the criterion most teams have never applied to a grid. It was added in WCAG 2.2 and it says plainly that any function operated by a dragging movement must also be operable with a single pointer without dragging — a click, or a tap. A resize handle that only responds to mousedown-move-mouseup fails it, regardless of how good the keyboard support is.
Prerequisites
Permalink to "Prerequisites"Read these first; the patterns here assume them:
- Semantic HTML table construction — column operations only preserve relationships if there were relationships to preserve.
- Correct usage of scope and headers — reordering a column moves its
scope="col"header with it, which is exactly whyheaders/idwiring must be re-derived rather than hard-coded. - Roving tabindex for custom data grids — the grips join the same focus model as the cells.
- Announcing async loading states — a column change that triggers a refetch needs the same loading discipline.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Attribute | Valid values | Apply it to | Common misuse |
|---|---|---|---|
role="separator" |
— | The focusable resize grip | Using role="slider", which implies a value the user chose rather than a boundary |
aria-orientation |
horizontal, vertical |
The grip | Omitting it, leaving the reader to guess which axis moves |
aria-valuenow |
Integer | The grip | Reporting a percentage while the visible unit is pixels |
aria-valuemin / aria-valuemax |
Integer | The grip | Setting them once and never updating after a container resize |
aria-colindex |
1-based integer | Each cell in a reorderable grid | Leaving stale indexes after a move |
aria-colcount |
Integer, or -1 | The grid container | Counting hidden columns in the total |
aria-colindex matters far more once columns can move. It is the only way a screen reader can tell the user that they are in column 3 of 7 when the DOM order no longer matches the original schema. Recompute it on every reorder, in the same pass that rewrites the DOM.
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Make the resize grip a real control (SC 2.1.1, 4.1.2)
Permalink to "Step 1 — Make the resize grip a real control (SC 2.1.1, 4.1.2)"<!-- SC 4.1.2: role + value make the grip announceable -->
<!-- SC 2.1.1: tabindex="0" puts it in the keyboard path -->
<th scope="col" style="width: 180px">
Amount
<span class="col-grip"
role="separator"
tabindex="0"
aria-orientation="vertical"
aria-label="Resize Amount column"
aria-valuenow="180"
aria-valuemin="80"
aria-valuemax="600"></span>
</th>
The grip is a separator because that is what it is: a movable boundary between two regions. Screen readers announce the label, the role and the current value, which is exactly the information a sighted user gets from the cursor and the column edge.
Step 2 — Handle the keys, not just the drag (SC 2.1.1, 2.5.7)
Permalink to "Step 2 — Handle the keys, not just the drag (SC 2.1.1, 2.5.7)"// SC 2.1.1: arrows resize; SC 2.5.7: a click-step alternative to dragging
const STEP = 16;
grip.addEventListener('keydown', (e) => {
const now = Number(grip.getAttribute('aria-valuenow'));
let next = now;
if (e.key === 'ArrowRight') next = now + STEP;
else if (e.key === 'ArrowLeft') next = now - STEP;
else if (e.key === 'Home') next = Number(grip.getAttribute('aria-valuemin'));
else if (e.key === 'End') next = Number(grip.getAttribute('aria-valuemax'));
else if (e.key === 'Escape') next = original; // cancel restores the start width
else return;
e.preventDefault(); // arrows must not scroll the grid
applyWidth(next); // sets the column width
grip.setAttribute('aria-valuenow', String(next)); // the reader reads the change
});
Setting aria-valuenow is what produces the announcement. There is no need for a separate live region: the separator role makes the value part of the control, so the reader speaks it as it changes without any extra plumbing.
Step 3 — Reorder by moving the DOM, then re-index (SC 1.3.2)
Permalink to "Step 3 — Reorder by moving the DOM, then re-index (SC 1.3.2)"A column move must move the actual cells. Anything that reorders columns visually while leaving the DOM alone — a CSS order property on a grid, for example — produces a table whose visual order and reading order disagree, which is precisely what SC 1.3.2 forbids.
// SC 1.3.2: visual order and DOM order must stay identical
function moveColumn(table, from, to) {
for (const row of table.rows) {
const cell = row.cells[from];
const ref = row.cells[to + (to > from ? 1 : 0)];
row.insertBefore(cell, ref ?? null); // real DOM move, every row
}
reindexColumns(table); // rewrite aria-colindex
announce(`Amount moved to position ${to + 1} of ${table.rows[0].cells.length}`);
}
Step 4 — Freeze with sticky positioning only (SC 1.3.1)
Permalink to "Step 4 — Freeze with sticky positioning only (SC 1.3.1)"/* SC 1.3.1: sticky changes painting, never structure */
.grid td.is-frozen,
.grid th.is-frozen {
position: sticky;
left: 0;
z-index: 2;
background: var(--color-surface); /* opaque, or rows show through */
}
The temptation — especially in older grid libraries — is to render the frozen columns into a second table positioned on top of the first. That destroys the row relationship: a screen reader now encounters two tables, neither of which contains a complete row. Sticky positioning costs nothing semantically and is supported everywhere that matters.
Keyboard interaction contract
Permalink to "Keyboard interaction contract"| Key | Context | Action | Failure indicator |
|---|---|---|---|
| Tab | Header row | Move to the next header, then its grip | The grip is skipped entirely |
| Enter / Space | Grip | Begin a resize interaction | Nothing happens without a mouse |
| Left / Right | Grip | Resize by one step | The page scrolls instead |
| Home / End | Grip | Jump to the minimum or maximum width | No response |
| Escape | Grip | Cancel and restore the previous width | The width stays changed |
| Ctrl + Left / Right | Header | Move the column one position | The move is mouse-only |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"The important row in that matrix is the last one. No screen reader announces that a column is frozen, because position: sticky has no semantic meaning. If freezing carries information the user needs — “this column stays visible while you scroll” — put it in the column header’s accessible name or in the toolbar control’s state, not in the CSS.
Edge cases & failure modes
Permalink to "Edge cases & failure modes"A resize that hides the header text. Users can drag a column narrow enough to truncate its header. The header is still in the DOM, so screen readers are unaffected, but sighted keyboard users lose the column identity. Enforce a aria-valuemin that keeps the header legible, and never let the minimum be zero.
Reordering while a sort is active. The sort state lives on a specific column. After a move, aria-sort must move with the column, not stay on the position. If you store sort state by column index rather than column id, a reorder silently transfers the sort indicator to a different column.
Freeze plus horizontal scroll plus focus. When a focused cell scrolls behind a frozen column, the browser considers it visible and will not scroll it into view. Add scroll padding equal to the frozen width so scrollIntoView clears the sticky region.
Column state restored from storage. A returning user gets their saved layout with no announcement. Announce the restored state once — “Restored your saved column layout: 5 of 7 columns visible” — and provide a reset control.
Keyboard-accessible column resizing
Permalink to "Keyboard-accessible column resizing"The resize grip is the single hardest control in this group to get right, because it is thin, it is easy to skip in the tab order, and its focus indicator is usually clipped by the header cell’s overflow. The dedicated walkthrough covers the separator role, the step size, and the focus-ring problem: keyboard-accessible column resizing.
Announcing a column reorder
Permalink to "Announcing a column reorder"Drag-and-drop reordering must have a non-drag alternative under SC 2.5.7, and the move must be announced with its destination. The detail page covers both the keyboard move commands and the announcement copy: announcing a column reorder without drag and drop.
Frozen columns and reading order
Permalink to "Frozen columns and reading order"Freezing is the operation most likely to be implemented in a way that destroys table semantics, because the naive implementation splits the table. The detail page shows the sticky-only implementation and the scroll-into-view fix: frozen columns and screen reader reading order.
Design system integration notes
Permalink to "Design system integration notes"Column controls are the part of a grid most often bolted on per product, and that is why they drift. A design system can settle the accessibility contract in one place and let every consumer inherit it.
Start with the tokens. A resize grip needs its own focus-ring token, because the default ring is usually tuned for buttons on a surface, and a grip sits on a header band that is a different colour. Give it a token that clears 3:1 against the header background in both themes, and give the grip a minimum hit area token — a 2 pixel visual line inside a 16 pixel target is a legitimate and common pattern, and it is much easier to get right when the padding is a token rather than a per-product guess.
Then the component API. Every column definition should carry a stable id that is independent of position, because sort state, filter state, saved layouts and aria-colindex all need something that survives a reorder. Systems that key column state by array index produce a specific class of bug that is invisible in testing and obvious in production: the sort indicator migrates to whichever column now occupies position three.
Expose the three operations as separate capabilities — resizable, movable, freezable — rather than one interactive flag. They have different accessibility obligations, and a product that only needs resizing should not inherit the announcement machinery for reordering.
Finally, own the announcements inside the component. A grid that lets each consumer write its own reorder message will end up with seven different phrasings, most of which omit the destination. Ship the message as part of the component, take an optional formatter for localisation, and cover it with a test that asserts the message contains the column name and the new position.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Does a resizable column need a real slider role?
Yes, if it can be operated. Give the grip role=“separator” with aria-orientation=“vertical”, tabindex=“0”, aria-valuenow, aria-valuemin and aria-valuemax. The separator role is the ARIA pattern for a focusable splitter, and it gives the screen reader a value to read as the width changes. A grip that is only a mouse target with no role fails SC 2.1.1 because there is no way to reach it.
Should hidden columns be removed from the DOM or hidden with CSS?
Remove them. A column hidden with visibility: hidden or zero width often stays in the accessibility tree, so a screen reader counts cells the user cannot see and the column header count no longer matches what is announced. If you keep the cells for layout reasons, mark them aria-hidden=“true” and update aria-colcount so the reported column count matches the visible grid.
How should a column reorder be announced?
With one message that names the column and its new position: “Amount moved to position 3 of 7.” Announce it in a polite region after the DOM has settled. Do not announce each intermediate position while the user holds the key down — coalesce the run and announce the final position once the key is released.
Related
Permalink to "Related"- Sortable & filterable data grids — sort state must survive every column move
- Semantic HTML table construction — the relationships column operations must not break
- Bulk selection & batch actions — the selection column is usually the one that stays frozen
- Composite widget roles and states —
aria-colindexandaria-colcountin context - Focus indicator contrast auditing — thin grips are where focus indicators fail first