Keyboard-Accessible Column Resizing

Permalink to "Keyboard-Accessible Column Resizing"

A column resize grip is a two-pixel-wide element that only exists to be dragged, which makes it the most reliably inaccessible control in a data grid. This page covers the exact markup and key handling that turn it into a real control: the separator role that gives it a name and a current value, the arrow-key handling that changes that value, and the focus-ring problem that makes a correctly implemented grip still appear broken. The single failure it prevents is a column that can only be resized with a pointer — a straight SC 2.1.1 Keyboard failure, and since WCAG 2.2, an SC 2.5.7 Dragging Movements failure as well.

It is the resize detail beneath resizable, reorderable and frozen columns, and it shares its focus model with roving tabindex for custom data grids.

Spec reference

Permalink to "Spec reference"

ARIA defines separator as a divider that separates and distinguishes sections of content. When a separator is focusable it becomes a widget rather than a structural element, and it takes the value attributes: aria-valuenow, aria-valuemin and aria-valuemax. That is precisely the semantics of a column grip — a boundary the user can move within bounds.

The value should be expressed in the same unit the user perceives. For a column that is 180 CSS pixels wide, aria-valuenow="180" is honest and useful; a percentage is not, because the user has no idea what it is a percentage of. Set aria-valuemin to the narrowest width at which the column header remains legible, and aria-valuemax to something that leaves the rest of the table usable.

Default behaviour without any of this is nothing at all. A div with a mousedown listener has no role, no accessible name and no value; it is not in the tab order; and no assistive technology has any way to discover that it exists.

Roles that fit a resize grip, and one that does not Comparison of role=separator with tabindex against role=slider and a bare div, showing what each communicates to assistive technology. Roles that fit a resize grip, and one that does not✓ role="separator" + tabindex="0"Announced as a splitter with a current valuearia-valuenow, valuemin and valuemax are allvalid on itArrow keys are the expected interaction✗ A bare div with a mousedown handlerNo role, no name, no valueNot reachable by keyboard at allFails SC 2.1.1 and SC 4.1.2 together
A grip is a movable boundary, which is exactly what the separator role describes.

When to use — and when not to

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

Use a focusable grip whenever columns are resizable at all. There is no threshold below which mouse-only resizing is acceptable: if the function exists, it needs a keyboard path.

Do not add a grip when the column widths are fixed by design and the user cannot change them. An inert separator that announces a value nobody can change is noise. If resizing is available only in an “edit layout” mode, put the grips in the tab order only while that mode is active, and announce entering the mode so the change in the tab order is not a surprise.

The common misapplication is role="slider". A slider implies a value the user is choosing as data — a price, a volume, a threshold. A column width is a boundary, and the separator role tells the reader that directly. The practical difference shows up in how readers phrase it: “splitter, 180” rather than “slider, 180”, which is more useful context for someone who cannot see the table.

Annotated code example

Permalink to "Annotated code example"
<!-- SC 4.1.2: role + name + value make the grip a real control -->
<!-- SC 2.1.1: tabindex puts it in the keyboard path -->
<th scope="col" style="width:180px">
  <span class="th-label">Amount</span>
  <span class="col-grip"
        role="separator"
        tabindex="0"
        aria-orientation="vertical"
        aria-label="Resize Amount column"
        aria-valuenow="180"
        aria-valuemin="96"
        aria-valuemax="640"></span>
</th>
// SC 2.1.1: the keyboard path; SC 2.5.7: also the non-dragging pointer path
const STEP = 16, COARSE = 64;
let original = null;

grip.addEventListener('focus', () => {
  original = Number(grip.getAttribute('aria-valuenow'));   // for Escape
});

grip.addEventListener('keydown', (e) => {
  const min = Number(grip.getAttribute('aria-valuemin'));
  const max = Number(grip.getAttribute('aria-valuemax'));
  const now = Number(grip.getAttribute('aria-valuenow'));
  const step = e.shiftKey ? COARSE : STEP;
  let next;

  switch (e.key) {
    case 'ArrowRight': next = now + step; break;
    case 'ArrowLeft':  next = now - step; break;
    case 'Home':       next = min; break;
    case 'End':        next = max; break;
    case 'Escape':     next = original; announce('Resize cancelled'); break;
    default: return;                       // let every other key through
  }

  e.preventDefault();                      // arrows must not scroll the grid
  next = Math.min(max, Math.max(min, next));
  applyWidth(column, next);                // sets the <col> or <th> width
  // Updating the value on the FOCUSED element is what produces the announcement
  grip.setAttribute('aria-valuenow', String(next));
});

Two details in that handler matter more than they look. preventDefault() is not optional: without it, Left and Right scroll a horizontally scrolling grid while also resizing, and the column the user is adjusting slides out from under them. And clamping before applying means the announced value is always the value that was actually used — an unclamped aria-valuenow of 720 on a column that stopped at 640 is a lie the user cannot see through.

Keys the resize grip must handle Keyboard contract for a column resize grip: arrows resize by a step, Shift plus arrows resize by a larger step, Home and End reach the bounds, Escape cancels. Keys the resize grip must handleLeft / Right arrowResize by one step, 16 pixelsaria-valuenow changes and is announced by theseparator roleShift + Left / RightResize by a coarse step, 64 pixelsSame announcement; useful on very wide columnsHomeSnap to the minimum widthAnnounce the value, not "minimum"EndSnap to the maximum widthAnnounce the valueEscapeCancel and restore the width at focustimeAnnounce "resize cancelled" in a polite region
Every key here updates one attribute and one width — nothing else.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Assistive technology On focus On value change Deviation
NVDA + Firefox “Resize Amount column, splitter, 180” Reads the new number on each step None significant
JAWS + Chrome “Resize Amount column, splitter” Reads the number; may omit the unit Announces the label again after a pause in stepping
VoiceOver + Safari “Resize Amount column, splitter, 180” Reads the value on focus and after stepping stops Does not read every intermediate step
TalkBack + Chrome Reads the label Value announced when stepping settles Rarely used — resizing is not a touch pattern

VoiceOver’s behaviour is worth planning around rather than fighting. It does not narrate every intermediate step, which is arguably better: a user holding the arrow key hears the final value rather than twelve numbers. Do not add a live region to force per-step announcements on it.

Integration context

Permalink to "Integration context"

The grip joins the header’s tab sequence, so it interacts directly with the header’s own controls. In a grid that already uses roving tabindex, decide deliberately whether grips are part of the roving set or separate tab stops. The simpler answer, and the one most component libraries land on, is that the header button and its grip are two ordinary tab stops within the header row, and the roving model applies only to the body cells.

Widths must survive the operations described in the parent guide. After a column reorder, the grip belongs to the column, not the position — another reason to key column state by a stable id rather than an index.

Where the focus ring goes wrong on a grip Three-part diagram showing a grip focus ring clipped by header overflow, and the two fixes: an inset outline and a box-shadow ring. Where the focus ring goes wrong on a gripThe problemoverflow: hidden on the header clips the ringThe grip appears to take focus silentlyFix A — inset the ringoutline-offset: -2px keeps it inside the cellSimplest, works everywhereFix B — draw with box-shadowbox-shadow is not clipped the same wayUse when the grip overlaps two cells
A 2 pixel ring on a 2 pixel element is invisible if the header clips it.

Gotchas

Permalink to "Gotchas"

The focus ring is clipped. Header cells frequently carry overflow: hidden to keep long labels from spilling. A 2 pixel outline on a 2 pixel grip is then invisible. Use outline-offset: -2px so the ring is drawn inside the cell, or draw the indicator with box-shadow, which is not clipped by the same rules. Verify it against both the default and the hover header background — this is exactly the scenario focus indicator contrast auditing exists for.

The grip is too small to hit. SC 2.5.8 asks for a 24 by 24 CSS pixel minimum target. A 2 pixel visual line inside a 16 or 24 pixel transparent hit area satisfies both the design and the criterion; the visual width and the target size are independent.

aria-valuemin is zero. A column that can be dragged to nothing removes the header text from sighted users while leaving the cells in the accessibility tree, so the two experiences diverge. Set the minimum to a width that keeps the header legible.

The value goes stale after a container resize. If the table is responsive, aria-valuemax computed at mount will be wrong after the window changes. Recompute the bounds on resize and update the attributes, or the announced maximum will not match the width the user can actually reach.

FAQ

Permalink to "FAQ"
What step size should arrow keys use for column resizing?

Sixteen CSS pixels is a good default: large enough that a user reaches a useful width in a few presses, small enough to land on a specific value. Offer a coarse step on Shift plus arrow — 64 pixels works well — for very wide columns. Avoid percentage steps, because the announced value then changes meaning as the container resizes.

Should the resize be applied live or only on release?

Live. The whole point of the keyboard path is that the user hears the value as it changes, and a resize that only applies when the interaction ends gives them nothing to steer by. Apply each step immediately and update aria-valuenow in the same handler. Keep the original width in a variable so Escape can restore it.

Do I need a live region for the width announcement?

No, and adding one causes a double announcement. The separator role makes aria-valuenow part of the control, so screen readers speak the new value when the attribute changes on the focused element. A separate polite region is only needed for the cancel message, which is not a value change.

Permalink to "Related"

Back to Resizable, Reorderable & Frozen Columns