Others
Window Splitter
AvailableA resize handle that moves the boundary between two panes via drag or keyboard.
What Is a Window Splitter?
A window splitter is a handle at the boundary of two regions (panes) that lets users adjust the size of each pane. You see them in file explorers ("folder list | contents"), editors ("sidebar | main text"), and email apps ("list | message body").
With a mouse, dragging the handle is intuitive, but the problem is people who don't use a mouse. Keyboard and screen reader users also need to know "this is a resizable boundary, and its current position is X%."
Why Does Accessibility Matter?
Building a drag-only splitter leaves these people behind:
- Keyboard-only users. If the divider is a
<div>, it can't be focused with Tab, so resizing is simply impossible. - Screen reader users. A plain
<div>is not recognized as a "separator" or "currently at 30%," so users can't even tell it's an interactive control.
The solution is to make the handle role="separator" + tabindex="0", convey the current value with aria-valuenow / valuemin / valuemax, and enable resizing via arrow keys.
Live Demo (Recommended Implementation)
The splitter below follows the APG specification.Without using the mouse, focus the boundary handle and try resizing with arrow keys.
Try it: Tab to focus the handle → ← → to resize → Home / End for minimum / maximum → Enter to collapse / restore.
Tip
Turn on a screen reader (on macOS, press ⌘+F5 to toggle VoiceOver) and focus the handle. You'll hear something like "Navigation width, splitter, 30" —name, role, and current value. As you resize with arrow keys, the value is announced each time. This is the effect of aria-valuenow.
Keyboard Interaction
| Key | Action | Priority |
|---|---|---|
| ← / → | Shrink / grow the pane (for a vertical splitter; use Up / Down for horizontal) | Required |
| Home | Set to the minimum size | Required |
| End | Set to the maximum size | Required |
| Enter | Collapse / restore to previous size (optional, recommended) | Optional |
| Tab | Move to the next / previous focusable element | Required |
Note
If the boundary is a vertical line (dividing left and right panes), use left/right arrows; if it's a horizontal line (dividing top and bottom panes), use up/down arrows.aria-orientation represents the orientation of the boundary line itself — a vertical line uses vertical.
Required WAI-ARIA Roles & Properties
| Target | Attribute / Role | Meaning |
|---|---|---|
| Handle | role="separator" | Conveys to assistive technology that this is a resizable separator. |
| Handle | tabindex="0" | Makes the div focusable so it can be reached via Tab. |
| Handle | aria-valuenow="30" | The current ratio (or size). Must be updated on every change. |
| Handle | aria-valuemin / aria-valuemax | The minimum and maximum range of movement. Values are clamped to this range. |
| Handle | aria-controls="<pane id>" | Indicates which pane is being resized. |
| Handle | aria-label or aria-labelledby | A name describing what is being resized. Required because the element is focusable. |
| Handle | aria-orientation="vertical" | The orientation of the separator line. Use vertical for a vertical line (default is horizontal). |
Recommended Pattern (Good)
Good / Recommended
Make the handle role="separator" + tabindex="0", and keep aria-valuenow in sync with the visual width.
Markup:
<div class="splitter">
<!-- Left pane (resize target) -->
<div id="primary-pane" class="pane">
Navigation
</div>
<!-- Boundary handle: this is the window splitter -->
<div role="separator"
tabindex="0"
aria-orientation="vertical"
aria-label="Navigation width"
aria-controls="primary-pane"
aria-valuenow="30"
aria-valuemin="10"
aria-valuemax="80"></div>
<!-- Right pane (fills remaining space) -->
<div class="pane">
Main content
</div>
</div>Resize script (syncing aria-valuenow with width is everything):
const splitter = document.querySelector('.splitter');
const handle = splitter?.querySelector('[role="separator"]');
const primary = document.getElementById('primary-pane');
if (splitter instanceof HTMLElement && handle instanceof HTMLElement && primary) {
const MIN = Number(handle.getAttribute('aria-valuemin')); // 10
const MAX = Number(handle.getAttribute('aria-valuemax')); // 80
const STEP = 5;
let prev = Number(handle.getAttribute('aria-valuenow')); // Remember value before collapse
// Keeping aria-valuenow (%) in sync with the visual width is the key
const apply = (value) => {
const v = Math.min(MAX, Math.max(MIN, Math.round(value)));
handle.setAttribute('aria-valuenow', String(v));
primary.style.flexBasis = v + '%';
};
handle.addEventListener('keydown', (e) => {
const now = Number(handle.getAttribute('aria-valuenow'));
switch (e.key) {
case 'ArrowLeft': apply(now - STEP); break; // Narrow left pane
case 'ArrowRight': apply(now + STEP); break; // Widen left pane
case 'Home': apply(MIN); break; // Minimum
case 'End': apply(MAX); break; // Maximum
case 'Enter': // Collapse ⇄ restore
if (now > MIN) { prev = now; apply(MIN); }
else { apply(prev > MIN ? prev : MAX); }
break;
default: return; // Ignore unrelated keys
}
e.preventDefault();
});
}Note
Mouse drag support can be added on top. First make keyboard and aria-valuenow work reliably, then layer drag support on top — this order makes the implementation more robust.
Anti-Pattern (Bad)
Below is a "visually identical" splitter with a <div> divider that only has mousedown drag.It works with a mouse but is completely inoperable via keyboard.Try pressing Tab → ← → to feel the difference from the demo above.
Try it: Tab doesn't focus the boundary. Screen readers don't recognize it as a 'separator' or 'currently at 30%,' and users can't even tell it's resizable.
<!-- ❌ Anti-pattern: drag-only div divider -->
<div class="splitter">
<div id="left" class="pane">Navigation</div>
<!-- No role, tabindex, or aria-value*. Just a div -->
<div class="divider" onmousedown="startDrag()"></div>
<div class="pane">Main content</div>
</div>
<script>
// Only works with mouse drag (keyboard is completely ignored)
function startDrag() {
document.onmousemove = (e) => {
document.getElementById('left').style.flexBasis = e.clientX + 'px';
};
document.onmouseup = () => (document.onmousemove = null);
}
</script>Bad / Avoid
Problems with this implementation:
- Not recognized as a separator — Without
role="separator", it doesn't appear as an interactive control. - Current value and range not conveyed — Without
aria-valuenow / valuemin / valuemax, users can't tell the current percentage or the limits. - Not focusable — Without
tabindex, it can't be reached with Tab. - Not keyboard resizable — No arrow key or Home/End handling; mouse only.
- No collapse — No alternative mechanism like Enter to collapse/restore.
Tip
If you must use a <div>, you need to add role="separator", tabindex="0", a name (aria-label), aria-valuenow/min/max, and arrow key handlingall by yourself. Drag alone is only half the implementation.
Implementation Checklist
- The handle has role="separator"
- The handle is focusable with tabindex="0"
- A name is provided via aria-label / aria-labelledby
- aria-valuenow always matches the current state and is in sync with the visual width
- aria-valuemin / aria-valuemax are set and values are clamped within range
- aria-controls points to the target pane
- Arrow keys resize; Home / End set to minimum / maximum
- (Optional) Enter collapses / restores to previous size
- Operable by keyboard alone with visible focus (focus ring is not removed)
Source (English):Window Splitter Pattern — W3C APG(opens in a new tab)