Slider: VoiceOver announces stale value when navigating with VO cursor (blur/focus cycle fires before value update)
- Dominant language
- TypeScript
- Stars
- 10.9k
- Forks
- 543
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 101
Description
## Summary
When a VoiceOver user navigates to a `Slider` using the **VO cursor** (Control+Option+Arrow) instead of Tab, the screen reader announces the **previous value** instead of the new one after pressing an arrow key to change the value.
[Screen recored](https://app.screencast.com/Wj1zwyfjub04T?conversation=ya6oUlBRQQ5gutwVWZuR4h)
## Root cause
When the slider input does not have `:focus-visible` (which is the case after VO cursor navigation — VoiceOver doesn't trigger `:focus-visible` the same way Tab does), Base UI's internal `onKeyDown` handler does a **blur → focus** cycle to activate `:focus-visible`:
```js
if (!matchesFocusVisible(input)) {
input.blur(); // VoiceOver reads aria-valuenow HERE (stale)
input.focus({ focusVisible: true }); // refocus
}
handleInputChange(event); // aria-valuenow updated HERE — too late
```
VoiceOver reads `aria-valuenow` at the moment of the `blur` event — before `handleInputChange` has run — so it always announces the old value.
## Steps to reproduce
1. Open any page with a `` component (single or range)
2. Enable **VoiceOver** on macOS (Command+F5)
3. Navigate to the slider using **VO cursor** (Control+Option+Right arrow) — do **not** use Tab
4. Enter slider interaction mode: **Control+Option+Shift+Down arrow**
5. Change the slider value: **Control+Option+Right arrow** (or Left)
6. Listen to what VoiceOver announces
**Expected:** VoiceOver announces the **new** value (e.g. "51")
**Actual:** VoiceOver announces the **previous** value (e.g. "50")
> Note: navigating with **Tab** works correctly because Tab sets `:focus-visible`, which skips the blur/focus cycle entirely.
## Environment
- `@base-ui/react` v1.0.0 (and latest)
- macOS Safari + VoiceOver
- Range slider (two thumbs) and single-thumb slider both affected
## Suggested fix
Update the value (`handleInputChange`) **before** performing the blur/focus cycle, so `aria-valuenow` reflects the new value at the time of the blur event — which is when VoiceOver reads it.
```js
handleInputChange(event); // update value FIRST
if (!matchesFocusVisible(input)) {
input.blur();
input.focus({ focusVisible: true });
}
```
Or alternatively, skip the blur/focus cycle entirely when an assistive technology is detected (e.g. via the `Navigator.userAgent` or a focus-visible polyfill approach).
Contributor guide
Research direction
Locate the Slider implementation's internal onKeyDown handler and handleInputChange logic, then reproduce the issue with VoiceOver using VO cursor navigation rather than Tab. Ensure the value update occurs before any blur/focus cycle, and verify that single and range sliders announce the new value on macOS VoiceOver.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100