adobe / adobe/react-spectrum

FocusScope's Tab interception defeats browser-native keyboard-focusable scroll containers, scrollable content in a Modal Dialog cannot be scrolled by keyboard (WCAG 2.1.1)

Open
#10,395 4 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
15.9k
Forks
1.6k
Avg merge
3d 9m
Merged PRs (30d)
59

Description

### Provide a general summary of the issue here

Since Chrome 127 and Firefox, browsers automatically include scrollable containers without focusable children in the sequential tab order, so keyboard users can focus them and scroll with arrow keys ([Chrome feature: keyboard-focusable scrollers](https://developer.chrome.com/blog/keyboard-focusable-scrollers)). Inside a Dialog, FocusScope intercepts every Tab keypress (preventDefault) and moves focus itself using its own focusable-tree walker. That walker only recognizes conventionally focusable elements, so an implicitly keyboard-focusable scroll container is skipped entirely. As a result, a dialog with a scrollable content region (pinned header/footer with `overflow: auto` body, a very common dialog layout) is impossible to scroll with the keyboard: focus can never reach the scroller, and there is nothing arrow keys will scroll. This fails WCAG 2.1.1 (Keyboard) and axe's scrollable-region-focusable rule.

### 🤔 Expected Behavior?

Tab (as managed by FocusScope inside a Dialog) should be able to reach a scrollable container that the browser itself would place in the tab order, matching native behavior outside the focus trap. Once focused, arrow keys / PageUp / PageDown / Home / End scroll it natively.

### 😯 Current Behavior

Tab cycles only between conventionally focusable elements (buttons, inputs, [tabindex]). The scrollable region is unreachable; keyboard scrolling is impossible.

Reproduced by Claude Fable via iinstrumented reproduction in Chromium 149:

Without tabIndex on the scroller: Tab visits only the dialog's buttons (keydown for Tab arrives with defaultPrevented: true at the document, FocusScope moved focus itself). After ArrowDown ×2 + PageDown, the scroller's scrollTop is still 0.
With an explicit tabIndex={0} on the same element: Tab reaches it and native scrolling works immediately (ArrowDown → 40, PageDown → 550, End → bottom).
The same markup outside a FocusScope is keyboard-scrollable in Chrome 127+/Firefox with no tabindex, because native Tab handling includes the scroller.

### 💁 Possible Solution

Teach the focusable/tabbable tree walker used by FocusScope to treat an element as tabbable when the browser would: a scroll container (computed overflow scrollable and `scrollHeight > clientHeight` / `scrollWidth > clientWidth`) containing no other focusable elements, i.e. mirror Chrome's keyboard-focusable-scrollers heuristic. Alternatively (or until then), document that scrollable regions inside `Dialog` require an explicit `tabIndex={0}`.

Workaround we use today: hardcode `tabIndex={0}` on the dialog body. It works, but it adds a permanent tab stop even when the content doesn't overflow, which browsers' native heuristic avoids.

### 🔦 Context

We build a design-system Dialog on react-aria-components with a pinned header/footer and a scrolling body

```CSS
.dialog {
display: flex;
flex-direction: column;
max-height: 80dvh;
overflow: hidden;
}

.dialog__body {
overflow-y: auto;
}
```

Our Storybook axe checks flag `scrollable-region-focusable`, and manual testing confirms keyboard users genuinely cannot scroll long dialog content (e.g. terms of service, multi-step forms). Comparable dialog implementations whose focus traps preserve native Tab handling via sentinel/guard elements get this right automatically in Chrome/Firefox — the regression is specific to focus traps that re-implement Tab navigation, which is what react-aria's FocusScope does. Related: #6729 (native video controls skipped by the same walker), #9570 (pointer interaction with scrollable content under contain).

### 🖥️ Steps to Reproduce

Created with the help of Claude AI.

https://stackblitz.com/edit/rac-dialog-scroll-issue?file=src%2FApp.tsx

1. Open the dialog with the keyboard (Enter on the trigger).
2. Press Tab repeatedly — focus only ever reaches the Close button; the scrollable

is never focused.
3. Press ArrowDown / PageDown — nothing scrolls (scrollTop stays 0).
4. Control: render the same scrollable
outside the Modal in Chrome 127+ — Tab focuses it natively and arrow keys scroll it.
5. Control 2: add tabIndex={0} to the
inside the Modal — Tab now reaches it and keyboard scrolling works.

### Version

react-aria-components: 1.19.0 (react-aria 3.50.0)

### What browsers are you seeing the problem on?

Chrome, Firefox

### If other, please specify.

Browsers: Chromium 149.0.7827.55 (behavior applies to Chrome 127+ and Firefox, which ship keyboard-focusable scrollers; Safari lacks the native feature so it's equally broken there either way)

### What operating system are you using?

Windows 11

### 🧢 Your Company/Team

_No response_

### 🕷 Tracking Issue

_No response_

Contributor guide

Open the contributing guide

Research direction

Start with the FocusScope tabbable-tree walker and run the linked StackBlitz reproduction in Chrome or Firefox. Compare tab behavior for the scrollable dialog body with and without tabIndex={0}, then inspect the related FocusScope behavior noted in issues #6729 and #9570. Done means keyboard users can reach and natively scroll an overflowing dialog container without requiring an unnecessary permanent tabIndex.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.