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 separator role — 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) and aria-controls pointing 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.
The splitter between two panes Layout mock with three columns: the list pane on the left, the focused separator in the middle, and the detail pane on the right, with the separator's value of 40 percent shown. The splitter between two panesInvoices listSplitter 40%INV-1042 detailsINV-1041⋮Customer: Lund & CoINV-1042⋮Amount: 1,240.00INV-1043⋮Status: Overdue
The separator is a focusable control with a value, not just a line.

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.

Drag-only divider versus window splitter Comparison: a drag-only div is unreachable by keyboard and hard to target; a window splitter is focusable, named, has a value, responds to keys and offers collapse. Drag-only divider versus window splitter✗ Drag-only divNot focusableNo role, no value1px hit areaNo way to collapseSize lost on reload✓ Window splitterTab stop with a visible focus ringrole="separator", aria-valuenow="40"24px target with a visible gripEnter collapses and restoresSize persisted per user

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.” —
Splitter value announcements Matrix of NVDA, JAWS, VoiceOver and TalkBack against announcing the role, the value on focus, and value changes on arrow keys. Splitter value announcementsRole on focusValue on focusValue on changeNVDA + ChromeseparatorYesYesJAWS + ChromeseparatorYesYesVoiceOver + SafarisplitterYesSometimes silentTalkBack + ChromeGenericPartialRarely
Add a status message for collapse so every reader confirms it.

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.

Permalink to "Related"

← Back to Master-Detail & Split Views