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.
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.
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.
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.
Related
Permalink to "Related"- Resizable, reorderable & frozen columns — the full column-manipulation model this grip belongs to
- Announcing a column reorder without drag and drop — the other SC 2.5.7 control in the same header
- Focus indicator contrast auditing — measuring the ring on a two-pixel control