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.

The three column operations and what each one changes Stack of three bands: resizing changes only geometry, reordering changes DOM order and therefore reading order, and freezing changes painting but must not change DOM order. The three column operations and what each one changesResizechanges width only — no DOM movement, no reading-order changeReorderchanges DOM order, so it changes the reading order for every screen readerFreezechanges painting via sticky positioning — DOM order must stay untouched
Only one of the three is safe to implement with CSS alone.

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:

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.

The keyboard contract for column manipulation Keyboard contract listing the keys that must operate column resize, reorder and freeze without a pointer, and the announcement each produces. The keyboard contract for column manipulationEnter on the gripEnter column resize modeAnnounce "Amount column, resize mode, 180 pixels"Left / Right arrowResize by one step (typically 16px)Announce the new width as an aria-valuenow changeEscapeCancel and restore the previous widthAnnounce "resize cancelled"Ctrl + Left / RightMove the column one positionAnnounce "Amount moved to position 3 of 7"Enter on FreezePin the column to the left edgeAnnounce "Amount column frozen"
Every drag gesture needs a keyboard equivalent — this is SC 2.1.1, not a nicety.

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.

Why a frozen column must not move in the DOM Three-step diagram showing that sticky positioning keeps the DOM order intact while a JavaScript re-parent breaks the row relationship for assistive technology. Why a frozen column must not move in the DOMSticky positioningposition: sticky; left: 0 on the cellsDOM order unchanged, row semantics intactRe-parenting into a second tablethe frozen column becomes its own <table>Row relationships are destroyedWhat the reader hearstwo unrelated tables instead of oneNever split a table to freeze a column
Sticky is a paint-time effect; re-parenting is a semantic change.

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"
What each screen reader reports during column operations Matrix comparing NVDA, JAWS and VoiceOver announcements for a resize slider, a column move and a frozen column. What each screen reader reports during column operationsNVDA + FirefoxJAWS + ChromeVoiceOver + SafariResize slider valuereads aria-valuenow and theunitreads the value, unitsometimes droppedreads the value on focus,not on every stepColumn movednew order read on nexttraversalnew order read on nexttraversalnew order read on nexttraversalFrozen columnno announcement —position:sticky is invisibleto itno announcementno announcement
Reordering is the only one all three readers reflect automatically — because the DOM changed.

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.

Persisting column state without stranding the user Comparison of column-state persistence that keeps a returning user oriented against persistence that silently changes the table under them. Persisting column state without stranding the user✓ OrientingAnnounce the restored layout once on loadOffer a "Reset columns" control in the sametoolbarStore per user, per table, not globally✗ DisorientingSilently restoring a hidden or reordered setNo way back to the default orderRestoring a width so narrow the headertruncates
A restored layout that is never announced reads as a different table.

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.

Column state: what to store and where Matrix of the three column settings against the storage scope each one belongs in and the accessibility consequence of restoring it silently. Column state: what to store and whereStore perIf restored silentlyWidthuser + tableharmless — nothing semantic changedOrderuser + tablethe reading order differs from thedocumented oneVisibilityuser + tablethe column count no longer matches theschema
Width is cosmetic; order and visibility change what the reader hears.

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.

Permalink to "Related"

Back to Accessible Data Tables & Grid Systems