# Resizable panels you can only resize by dragging

Source: https://easeweb.dev/learn/window-splitter
Topics: Drag and drop > Resizable panels can be resized with a keyboard (WCAG 2.1.1, 2.5.7, 4.1.2)
The fix: Either make the divider a focusable separator that says its width and moves with the arrow keys, Home and End, and add Narrower and Wider buttons; or keep the drag-only divider, hide it from assistive technology and add the buttons alone. A separator on its own still fails 2.5.7: someone who can't drag has no way to change the width with clicks or taps.
Test with: keyboard, screen reader, mouse, touch, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/ https://www.w3.org/TR/wai-aria-1.2/#separator https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F108 https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/separator_role

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A notes app has two panels: a sidebar listing the notes, and the open note beside it. Note titles that are too long for the sidebar are cut short, so the app lets people widen it: a thin line runs between the panels, the pointer turns into a left and right arrow over it, and a drag moves it anywhere from a fifth to half of the window. Press Tab and focus goes through the notes in the sidebar, then jumps to the note's Share button. The line is never a stop, and nothing on the page changes the width any other way.

Some layouts mark the line up as a separator, with `role="separator"` and `aria-orientation="vertical"`. Nothing changes for the keyboard: Tab still passes it, and the arrow keys do nothing. A screen reader reading down the page finds, at most, a separator line, with no width and nothing to change.

## Why it fails

The divider is an empty `div` that a script moves on pointer events. It isn't a link, a button or a field, and it has no `tabindex`, so the browser gives it no Tab stop and no keys. Changing the sidebar's width is something the app lets people do, and none of it works from the keyboard, which fails 2.1.1.

The only way to change the width is to drag, which fails 2.5.7. Resizing a panel doesn't need a drag: nothing about the result depends on the path the pointer takes. And keyboard support doesn't settle it either. The Understanding page for 2.5.7 says that "achieving keyboard equivalence for a dragging operation does not automatically meet this success criterion": someone on a touch screen, with a head pointer or with a mouse they can't hold down may have no arrow keys to fall back on, and needs something they can click or tap.

The divider is also a control the script makes work, with no role, name or value, which fails 4.1.2. Adding `role="separator"` doesn't help on its own. ARIA defines two kinds of separator: a static one, which is a visible boundary like `hr`, and a focusable one, which is a widget whose `aria-valuenow` gives its position. A separator that can't take focus is the static kind, so the role now tells assistive technology there is nothing here to operate, and no value says how wide the sidebar is. Its `aria-orientation="vertical"` is right, since a separator is horizontal unless it says otherwise, but it describes a line, not a control.

## Who is affected

People resize a sidebar when the main panel is too cramped to read or write in, or when the sidebar cuts their notes' names short. Both happen most to people who zoom, because zoom makes everything in each panel larger in relation to the window. In an app they use every day, a width they can't change is a cost they pay all day.

## What automation and AI miss

A scanner sees an empty `div`, which needs no name, or a separator that can't take focus, which is the static kind and needs nothing either. So neither Before breaks a rule it knows. axe's `aria-required-attr` does report a separator that takes focus but has no `aria-valuenow`, so a half-finished fix is found, but the failure itself isn't. EaseWeb's audit sends a review lead for a thin strip with a resize pointer that takes no focus, because only a person can try whether the width can be changed some other way on the page. Asked to make the divider accessible, a code assistant may add `tabindex="0"` and `role="separator"` and stop: the divider is then a Tab stop that does nothing, with no value, and there is still no way to resize without a drag.

## Before: a divider that only a drag moves

```html
<div class="notes-app">
  <nav class="sidebar" id="sidebar" aria-labelledby="notes-title" style="width: 30%">
    <h2 id="notes-title">Notes</h2>
    <ul>
      <li>
        <a href="/notes/packing-list" aria-current="page">Packing list</a>
      </li>
      …
    </ul>
  </nav>
  <div class="divider"></div>
  <main class="note">
    <h1>Packing list</h1>
    <button type="button">Share</button>
    …
  </main>
</div>
```

```js
const sidebar = document.getElementById('sidebar');
const divider = document.querySelector('.divider');
divider.addEventListener('pointerdown', startResize);
// pointermove sets the sidebar's width, from 20% to 50% of the app; pointerup ends the resize
```

- The divider can't take focus, so the keyboard never reaches it.
- A drag is the only way to move it.
- It has no role or value, so assistive technology isn't told there is a control here, or how wide the sidebar is.

## Before: the same divider with role="separator"

```html
<div class="notes-app">
  <nav class="sidebar" id="sidebar" aria-labelledby="notes-title" style="width: 30%">
    <h2 id="notes-title">Notes</h2>
    <ul>
      <li>
        <a href="/notes/packing-list" aria-current="page">Packing list</a>
      </li>
      …
    </ul>
  </nav>
  <div class="divider" role="separator" aria-orientation="vertical"></div>
  <main class="note">
    <h1>Packing list</h1>
    <button type="button">Share</button>
    …
  </main>
</div>
```

```js
const sidebar = document.getElementById('sidebar');
const divider = document.querySelector('.divider');
divider.addEventListener('pointerdown', startResize);
// pointermove sets the sidebar's width, from 20% to 50% of the app; pointerup ends the resize
```

The role is the only change. A separator with no `tabindex` is the static kind, a line between sections, so:

- It still takes no focus and no keys.
- A drag is still the only way to move it.
- Nothing gives its value.

## After: a splitter that takes the arrow keys, with Narrower and Wider buttons

```html
<div class="notes-app">
  <nav class="sidebar" id="sidebar" aria-labelledby="notes-title" style="width: 30%">
    <h2 id="notes-title">Notes</h2>
    <ul>
      <li>
        <a href="/notes/packing-list" aria-current="page">Packing list</a>
      </li>
      …
    </ul>
    <div class="sidebar-width" role="group" aria-label="Sidebar width">
      <button type="button" data-step="-5">Narrower</button>
      <button type="button" data-step="5">Wider</button>
    </div>
  </nav>
  <div class="divider" role="separator" tabindex="0" aria-orientation="vertical"
    aria-labelledby="notes-title" aria-controls="sidebar"
    aria-valuenow="30" aria-valuemin="20" aria-valuemax="50" aria-valuetext="30%"></div>
  <main class="note">
    <h1>Packing list</h1>
    <button type="button">Share</button>
    …
  </main>
</div>
```

```js
const sidebar = document.getElementById('sidebar');
const divider = document.querySelector('.divider');
const [MIN, MAX] = [20, 50];
const width = () => Number(divider.getAttribute('aria-valuenow'));
// Every way of resizing ends here: the drag, the keys and the buttons.
function setWidth(percent) {
  const to = Math.min(MAX, Math.max(MIN, Math.round(percent)));
  sidebar.style.width = `${to}%`;
  divider.setAttribute('aria-valuenow', to);
  divider.setAttribute('aria-valuetext', `${to}%`);
}
const KEYS = { ArrowLeft: () => width() - 5, ArrowRight: () => width() + 5, Home: () => MIN, End: () => MAX };
divider.addEventListener('keydown', (event) => {
  if (!Object.hasOwn(KEYS, event.key)) return;
  event.preventDefault();
  setWidth(KEYS[event.key]());
});
for (const button of document.querySelectorAll('[data-step]')) {
  button.addEventListener('click', () => setWidth(width() + Number(button.dataset.step)));
}
divider.addEventListener('pointerdown', startResize); // the drag calls setWidth() as it moves
```

1. Put the divider in the Tab order with `tabindex="0"`. A separator that takes focus is the focusable kind, a widget, which must have `aria-valuenow`.
2. Name it after the pane it resizes, "Notes", through `aria-labelledby`, and point `aria-controls` at that pane.
3. Give the sidebar's width as a share of the app with `aria-valuenow`, `aria-valuemin` and `aria-valuemax`, and say with `aria-valuetext` that the number is a percentage.
4. Move it by 5% with Left and Right Arrow, and to the narrowest and widest the sidebar can be with Home and End. `preventDefault()` stops the arrow keys from scrolling the page too.
5. Add Narrower and Wider buttons. They take single clicks and taps, so the width can be changed without a drag, which the splitter alone doesn't give. They are native buttons, so they are in the Tab order and voice control users can say "Click Wider". They step by the same 5% as the keys, so they reach every width the keys can.
6. Send every way of resizing through `setWidth()`, so the value a screen reader reads is always the width on screen. The drag stays, as a shortcut.

This fixes both Befores and passes 2.1.1, 2.5.7 and 4.1.2. The divider now follows the window splitter pattern in the ARIA Authoring Practices Guide, apart from Enter to collapse the pane (see Implementation decisions).

## After: Narrower and Wider buttons, the drag left as it was

```html
<div class="notes-app">
  <nav class="sidebar" id="sidebar" aria-labelledby="notes-title" style="width: 30%">
    <h2 id="notes-title">Notes</h2>
    <ul>
      <li>
        <a href="/notes/packing-list" aria-current="page">Packing list</a>
      </li>
      …
    </ul>
    <div class="sidebar-width" role="group" aria-label="Sidebar width">
      <button type="button" data-step="-5">Narrower</button>
      <button type="button" data-step="5">Wider</button>
    </div>
  </nav>
  <div class="divider" aria-hidden="true"></div>
  <main class="note">
    <h1>Packing list</h1>
    <button type="button">Share</button>
    …
  </main>
</div>
```

```js
const sidebar = document.getElementById('sidebar');
const divider = document.querySelector('.divider');
let width = 30;
// The drag and the buttons both end here.
function setWidth(percent) {
  width = Math.min(50, Math.max(20, Math.round(percent)));
  sidebar.style.width = `${width}%`;
}
for (const button of document.querySelectorAll('[data-step]')) {
  button.addEventListener('click', () => setWidth(width + Number(button.dataset.step)));
}
divider.addEventListener('pointerdown', startResize); // the drag calls setWidth() as it moves
```

1. Leave the divider as it was, for people who drag, and add two native buttons that do the same job with single clicks, taps or key presses. They step by 5% between 20% and 50%, the range the drag covers.
2. Keep the drag-only divider out of the accessibility tree with `aria-hidden="true"`. That matters when a library gives it `role="separator"`: a screen reader then finds the buttons, not a separator with nothing to change.
3. Send the drag and the buttons through the same `setWidth()`.

This is the smallest change, and it passes 2.1.1, 2.5.7 and 4.1.2. But the buttons have no value, so a screen reader doesn't say the width; for someone who can't see the panels, the width rarely matters, but someone who uses a screen reader with some sight, or with a magnifier, has to judge it by eye. Keyboard users also have to find the buttons in the sidebar rather than at the line they can see. So it is not enough on its own: it leaves part of the failure in place, since nothing says what the width is.

## After: a splitter that takes the arrow keys, on its own

```html
<div class="notes-app">
  <nav class="sidebar" id="sidebar" aria-labelledby="notes-title" style="width: 30%">
    <h2 id="notes-title">Notes</h2>
    <ul>
      <li>
        <a href="/notes/packing-list" aria-current="page">Packing list</a>
      </li>
      …
    </ul>
  </nav>
  <div class="divider" role="separator" tabindex="0" aria-orientation="vertical"
    aria-labelledby="notes-title" aria-controls="sidebar"
    aria-valuenow="30" aria-valuemin="20" aria-valuemax="50" aria-valuetext="30%"></div>
  <main class="note">
    <h1>Packing list</h1>
    <button type="button">Share</button>
    …
  </main>
</div>
```

```js
const sidebar = document.getElementById('sidebar');
const divider = document.querySelector('.divider');
const [MIN, MAX] = [20, 50];
const width = () => Number(divider.getAttribute('aria-valuenow'));
// The drag and the keys both end here.
function setWidth(percent) {
  const to = Math.min(MAX, Math.max(MIN, Math.round(percent)));
  sidebar.style.width = `${to}%`;
  divider.setAttribute('aria-valuenow', to);
  divider.setAttribute('aria-valuetext', `${to}%`);
}
const KEYS = { ArrowLeft: () => width() - 5, ArrowRight: () => width() + 5, Home: () => MIN, End: () => MAX };
divider.addEventListener('keydown', (event) => {
  if (!Object.hasOwn(KEYS, event.key)) return;
  event.preventDefault();
  setWidth(KEYS[event.key]());
});
divider.addEventListener('pointerdown', startResize); // the drag calls setWidth() as it moves
```

1. Make the divider the window splitter from the first fix, without the buttons: it takes focus, and says its name and width.
2. Move it with the arrow keys, Home and End, through the same `setWidth()` as the drag.

This passes 2.1.1 for keyboard users and 4.1.2 for screen reader users. It does not pass 2.5.7. Someone on a touch screen, with a head pointer or with a mouse they can't hold down still has to drag, and voice control users have to say each arrow key. Add the buttons, as the first fix does, rather than ship it alone.

## Which option to use

Use the splitter with the buttons: it is the only fix that leaves nothing out. It lets keyboard users resize from the line they can see, in the way the ARIA pattern describes, gives screen reader users the width, and takes single clicks and taps. It is the most code, and how a screen reader handles the arrow keys on a separator varies, so the buttons are its fallback as well as its pointer alternative. The buttons alone pass all three criteria and are the smallest change, needing no ARIA beyond hiding the divider, but no width is read and the buttons sit away from the line: they are a first step for a sidebar people resize now and then, until the divider can be made a splitter. The splitter on its own is not enough either: add the buttons to it.

## Implementation decisions

The value can be in any unit, as long as `aria-valuenow`, `aria-valuemin` and `aria-valuemax` use the same one and `aria-valuenow` changes with every drag, key and button. A share of the app, as here, keeps its meaning at any window size; a width in pixels works too. ARIA asks for `aria-valuetext` only when the number alone can't carry the value. A bare "30" doesn't say whether it is percent or pixels, so the fixes here add the unit ("30%"); leave it off where the number says it all.

A few things decide how the splitter behaves in a real layout:

- Orientation: a separator is horizontal unless it says otherwise. Panels side by side need `aria-orientation="vertical"` and Left and Right Arrow; panels one above the other, such as a list over a preview, keep the default and use Up and Down Arrow.
- Right-to-left layouts: APG's Left Arrow moves the divider to the left. With the sidebar on the right, that makes it wider, so read the direction from the page (`getComputedStyle(divider).direction`) and flip the keys to match.
- Collapsing: APG's pattern also has Enter collapse the pane and restore it, which the fixes here leave out. If you add it, add a visible collapse button too, for the same reason as the Narrower and Wider buttons, and while the pane is closed set `aria-valuetext` to say so ("Collapsed"), since its smallest value would read as a width it doesn't have.
- Focus: the divider is thin, so draw its focus ring around the whole strip that takes the drag, at 3:1 against both panels; see [low-contrast form fields, icons and focus rings](/learn/non-text-contrast).
- The drag: keep the strip that takes the drag wider than the line drawn, and set `touch-action: none` on it, so a drag on it doesn't scroll the page.
- Narrow windows: at 400% zoom or on a phone the panels should stack, or the sidebar become a drawer, as [pages that scroll sideways on a narrow screen](/learn/reflow) explains. Hide the divider and the buttons then (`display: none`), so no Tab stop is left that resizes nothing.
- Remembering the width: if the app keeps it between visits, set `aria-valuenow` and `aria-valuetext` from the saved width as the page loads, not only after the first change.

Panel libraries vary. Some give the handle `role="separator"` and `tabindex="0"` but no `aria-valuenow`, which axe reports; some give it `role="slider"`, which also takes the arrow keys and has a value, but is read as a slider, not as the line between two panels; some give it nothing at all. Check what the focused handle exposes and whether a click alone can change the width, not what the library's documentation promises.

## Verify the fix

1. Put the mouse aside. Press Tab through the app and confirm you reach the divider, or the buttons that change the width, in reading order.
2. From the keyboard alone, make the sidebar as narrow as it goes, then as wide, and back: with the arrow keys, Home and End on the divider, or with the buttons. Confirm the focus ring stays visible and the page doesn't scroll.
3. With a screen reader, Tab to the divider and confirm it is read with its name and its width, and that the width is read again after each arrow key. If the arrow keys read on through the page instead, confirm the buttons still change the width. With the buttons alone, nothing says the width: that is what that fix leaves out.
4. With a mouse using clicks only, and with taps on a touch screen, change the width without dragging. With voice control, change it by saying a button's text.
5. At 400% zoom, or in a window 320 CSS pixels wide, confirm the panels stack or the sidebar closes, and that no control is left that resizes nothing.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the layout or its panel library changes.

## Limits

This is a starting pattern, not a guarantee for every application. Screen readers differ in how they read a separator and whether they pass it the arrow keys, so test the ones your users have. A single slider for a price or a volume is covered in [range sliders that only move by dragging](/learn/sliders), moving cards and items in [drag-and-drop boards that only a mouse can use](/learn/drag-and-drop), and gestures in general in [pinch, drag and tilt gestures with no button](/learn/gestures). Resizable table columns, panels that can be docked or floated, and more than two panes in a row have needs of their own, and are not covered here. Test the complete layout with your target assistive technology.

## What screen readers do (tested by hand by EaseWeb)

`role="separator" tabindex="0"`: A divider between two panes that takes focus. ARIA makes it a widget whose aria-valuenow gives its position; the page’s script moves it with the arrow keys.

| Screen reader | Browser and system | Tab to it | Arrow keys |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
