adobe / adobe/react-spectrum

Allow customising focus restoration target when `ModalOverlay` trigger element is removed from DOM

Open
#9,876 0 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 feature here

Allow customising or disabling focus restoration when a `ModalOverlay` closes, so consumers can control where focus lands when the original trigger element has been removed from the DOM.

Perhaps there are already patterns to handle this but I could not find any!

### 🤔 Expected Behavior?

When a `ModalOverlay` closes and the element that was focused before it opened is no longer in the DOM, there should be a way to specify a fallback focus target or opt out of automatic restoration. This would prevent focus from falling to `document.body`.

### 😯 Current Behavior

`ModalOverlay` uses `useModalOverlay` which sets up a `FocusScope` with `restoreFocus` hardcoded to `true`. When the modal closes, `FocusScope` tries to restore focus to the previously focused element. If that element has been removed from the DOM, focus falls to `document.body`.

There is no prop on `ModalOverlay` or `Modal` to disable or redirect this behaviour.

This causes a visible focus flicker when consumers try to redirect focus using deferred calls (`setTimeout(0)` or `requestAnimationFrame`), since there is always at least one frame where focus sits on body before the deferred call runs. For screen reader users, this also leads to a brief and interrupted announcement of the `document.body` element.

### 💁 Possible Solution

Some potential options:

- Expose restoreFocus as a prop on `ModalOverlay`. Allowing `false` would let consumers opt out and manage focus themselves without racing against the default behaviour.
- `restoreFocusTo` prop — an element id (or ref but preference for id) that overrides the default restoration target.
- Fallback behaviour — when the original trigger element is no longer in the DOM, restore focus to an element specified by id instead of letting it fall to body.

### 🔦 Context

We have a confirmation dialog(s) where the triggering button is replaced by a loading state after the user confirms. We need focus to move to a parent container that displays the loading status. Our workaround of using setTimeout(0) in the onClose callback works functionally, but causes a visible single-frame flicker as focus briefly sits at body between `FocusScope` restoration and our deferred focus call.

Workarounds tried:
- `setTimeout(0)` in onClose — works but causes flicker
- `requestAnimationFrame` — same flicker
- `useEffect` cleanup watching the open prop — same timing issue
- Focusing before the dialog closes — blocked by the modal's focus trap

Related issues:
- #2761 — FocusScope loses focus to document.body when focused element is removed (fixed for in-scope case, not modal close)

### 💻 Examples

```
// Option 1: disable restoreFocus

// Option 2: specify a fallback target by id

// Option 3: callback for custom handling
{
if (document.contains(originalElement)) {
originalElement.focus()
} else {
document.getElementById('other-element')?.focus()
}
}}>
```

### 🧢 Your Company/Team

Genio (note-taking and presentation applications)

### 🕷 Tracking Issue

_No response_

Contributor guide

Open the contributing guide

Research direction

Start by tracing ModalOverlay and Modal through useModalOverlay, then inspect how FocusScope handles its hardcoded restoreFocus behavior when the original trigger is removed. Compare the proposed disable, fallback-target, and callback options, and verify the chosen behavior avoids document.body focus while preserving existing restoration behavior.

Written by the indexing model from the issue text.

Assessment

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.