Window Splitter Pattern for Resizable Panes
Permalink to "Window Splitter Pattern for Resizable Panes"The divider between a list pane and a detail pane is usually draggable: grab it and pull to give the table more room. Without keyboard support, users who need a wider table — magnifier users above all — cannot get one. The ARIA window splitter pattern turns the divider into a focusable, operable control: a separator with a value, adjusted with arrow keys, collapsed with Enter.
This guide belongs to master-detail & split views.
Spec reference
Permalink to "Spec reference"- WAI-ARIA
separatorrole — when focusable, a separator is a widget with a value. Required:aria-valuenow; recommended:aria-valuemin,aria-valuemax,aria-valuetext, an accessible name (often the primary pane’s name) andaria-controlspointing at the primary pane. - ARIA Authoring Practices window splitter pattern — Left/Right (or Up/Down) arrows move the splitter; Enter collapses and restores the primary pane; Home and End move to minimum and maximum; F6 is optional.
- SC 2.1.1 Keyboard (A) and SC 2.5.7 Dragging Movements (AA) — keyboard operation plus a single-pointer alternative, such as collapse buttons or a size menu.
- SC 2.5.8 Target Size (Minimum) (AA) — a 1px divider needs a wider hit area or spacing.
- SC 1.4.11 Non-text Contrast (AA) — the splitter’s focus indicator and, ideally, its visible grip reach 3:1.
When to use — and when not to
Permalink to "When to use — and when not to"Use a window splitter whenever panes are resizable by drag. Keyboard users and magnifier users often need the table wider than the default, and some need the detail pane collapsed entirely to read a wide table at high zoom.
Do not make a static divider focusable. A separator that is not adjustable is presentational (role="separator" without tabindex, or simply a border) and should not be a tab stop.
The misapplication: a draggable div with no role, no tabindex and a 1px hit area — invisible to assistive technology and nearly impossible to grab with a head pointer.
Annotated code example
Permalink to "Annotated code example"<div class="split" style="--primary: 40%">
<section id="list-pane" aria-labelledby="list-h">…</section>
<!-- Focusable separator = window splitter (WAI-ARIA separator, SC 2.1.1) -->
<div class="splitter" role="separator" tabindex="0"
aria-label="Invoices list width" aria-controls="list-pane"
aria-orientation="vertical"
aria-valuemin="20" aria-valuemax="80" aria-valuenow="40" aria-valuetext="40 percent"></div>
<section id="detail-pane" aria-labelledby="detail-h">…</section>
</div>
<!-- Single-pointer alternative (SC 2.5.7) -->
<button type="button" id="toggle-detail" aria-pressed="false">Hide details pane</button>
let value = 40, restore = 40;
const set = (v) => {
value = Math.max(0, Math.min(80, v));
split.style.setProperty('--primary', value + '%');
splitter.setAttribute('aria-valuenow', String(value));
splitter.setAttribute('aria-valuetext', value === 0 ? 'collapsed' : `${value} percent`);
localStorage.setItem('invoices-split', String(value)); // persist preference
};
splitter.addEventListener('keydown', (e) => {
const step = e.shiftKey ? 10 : 2;
if (e.key === 'ArrowLeft') { e.preventDefault(); set(value - step); }
if (e.key === 'ArrowRight') { e.preventDefault(); set(value + step); }
if (e.key === 'Home') { e.preventDefault(); set(20); }
if (e.key === 'End') { e.preventDefault(); set(80); }
if (e.key === 'Enter') { // collapse / restore (APG)
e.preventDefault();
if (value > 0) { restore = value; set(0); status.textContent = 'Invoices list collapsed.'; }
else { set(restore); status.textContent = 'Invoices list restored.'; }
}
});
.splitter { inline-size: 8px; cursor: col-resize; position: relative; }
.splitter::before { content: ""; position: absolute; inset: 0 -8px; } /* 24px target (SC 2.5.8) */
.splitter:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } /* SC 1.4.11 */
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Action | Expected announcement | AT deviations |
|---|---|---|---|
| Tab to splitter | Focus | “Invoices list width, separator, 40 percent” | VoiceOver: “splitter” or “separator” depending on version |
| Left / Right arrow | ±2% (±10% with Shift) | “38 percent” | Value changes announced in NVDA and JAWS; VoiceOver sometimes silent |
| Home / End | Min / max | “20 percent” / “80 percent” | — |
| Enter | Collapse / restore | “collapsed”, status “Invoices list collapsed.” | — |
Integration context
Permalink to "Integration context"Splitters sit between the panes described in keyboard navigation between list and detail panes and should be included in the F6 cycle only if users resize often. Column resizing in tables uses a closely related approach — see keyboard accessible column resizing. At high zoom, collapsing the detail pane is often what lets a wide table meet reflow expectations, discussed in meeting reflow 1.4.10 with wide tables.
Gotchas
Permalink to "Gotchas"Right-to-left layouts. Left and Right arrows should move the splitter in the visual direction. In RTL, invert the step or compute from getComputedStyle(el).direction.
Value semantics. aria-valuenow describes the primary pane’s size, not the divider’s pixel position. Keep it a percentage or pixel width of the pane named in aria-controls.
Pointer events and keyboard state. If the user drags, update aria-valuenow too, or the next keyboard step jumps from a stale value.
Framework notes
Permalink to "Framework notes"react-resizable-panels implements the window splitter pattern on its PanelResizeHandle (role separator, arrow keys, Home/End, Enter to collapse when collapsible is set) — pass a meaningful aria-label via props. Angular has no CDK splitter; angular-split provides one, but check its keyboard support and add role="separator" attributes if missing. For Vue, splitpanes supports drag only by default; wrap the handle with the key handler above.
<PanelGroup direction="horizontal" autoSaveId="invoices-split">
<Panel id="list" defaultSize={40} minSize={20} collapsible>…</Panel>
<PanelResizeHandle aria-label="Invoices list width" />
<Panel id="detail">…</Panel>
</PanelGroup>
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide a SplitView with built-in splitter semantics, step sizes, collapse behaviour and persistence keyed by view. Expose the step sizes as options, and document the keyboard contract in the component docs.
FAQ
Permalink to "FAQ"Is a resizable split view required by WCAG?
No. But if panes are resizable by drag, the resizing must be keyboard operable and have a single-pointer alternative.
Should the value be pixels or percent?
Percent is easier to understand when announced. If pixels are meaningful (for example, a fixed-width inspector), use aria-valuetext to say “320 pixels”.
Should the splitter be in the tab order between panes?
Yes, in the DOM between the two panes, so Tab reaches it naturally. Users who never resize simply Tab past it.
What should Enter do when a pane is already collapsed?
Restore it to its previous size and announce the restore.
Should the splitter be reachable with F6?
It is usually enough for it to be in the Tab order between the panes. Some applications include splitters in the F6 cycle; if you do, announce the splitter’s name and value on arrival so users know where they are.
How large should each arrow-key step be?
Small enough to be precise and large enough not to be tedious — 2 percent per press with 10 percent when Shift is held works for most layouts. For pixel-based panes, 16 and 64 pixels are equivalent choices.
Related
Permalink to "Related"- Navigating between panes — F6 and skip links
- Keyboard accessible column resizing — the column equivalent
- Zoom and reflow testing at 400% — why collapsing panes matters