Others

Window Splitter

Available

A 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:

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.

Accessible Window Splitter
Navigation
Main content

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

KeyActionPriority
/ Shrink / grow the pane (for a vertical splitter; use Up / Down for horizontal)Required
HomeSet to the minimum sizeRequired
EndSet to the maximum sizeRequired
EnterCollapse / restore to previous size (optional, recommended)Optional
TabMove to the next / previous focusable elementRequired

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

TargetAttribute / RoleMeaning
Handlerole="separator"Conveys to assistive technology that this is a resizable separator.
Handletabindex="0"Makes the div focusable so it can be reached via Tab.
Handlearia-valuenow="30"The current ratio (or size). Must be updated on every change.
Handlearia-valuemin / aria-valuemaxThe minimum and maximum range of movement. Values are clamped to this range.
Handlearia-controls="<pane id>"Indicates which pane is being resized.
Handlearia-label or aria-labelledbyA name describing what is being resized. Required because the element is focusable.
Handlearia-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.

Drag-only splitter built with divs
Navigation
Main content

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


Source (English):Window Splitter Pattern — W3C APG(opens in a new tab)