Skip to content

feat(modal): move focus into the modal and restore it on close - #5129

Draft
konstmar wants to merge 11 commits into
callstack:mainfrom
konstmar:overlay-focus-primitive
Draft

konstmar wants to merge 11 commits into
callstack:mainfrom
konstmar:overlay-focus-primitive

Conversation

@konstmar

Copy link
Copy Markdown
Contributor

Motivation

Current implementation of Modal lacks "initial focusing" and "focus restore on close".
This PR adds a shareable hook, useOverlayFocus, that moves focus into an overlay and restores it back on close.

  • Modal and Dialog are the first to use it. Other overlays adopt it separately.
  • On web the first tabbable element is focused; on native an accessibility focus event is sent.

Stacked on #5127 - the first eight commits belong to that PR and those below it.

Related issue

Notion

Screenshots / Videos

Test plan

Turn on VoiceOver/Talkback. Open a dialog in the example app: focus lands inside it, and returns to the button that opened it on close.

Konstantin Marushchak added 11 commits September 15, 2026 10:07
Re-provide `ReduceMotionContext` in `Portal`, alongside the settings, locale and
theme contexts already forwarded across the portal boundary, so portal content
stops falling back to the context default of `false`.
Compare the key when looking up the queued `mount` to replace, so an update that
arrives before the `PortalManager` ref is attached no longer overwrites an
unrelated queued portal.
Add an opt-in `overlay` prop to `Portal` that hides every layer below it -- the
app content and any portal mounted earlier -- from assistive technology and from
the web focus order, while portals mounted on top stay reachable.
Address review feedback on callstack#5126:

- rename the `overlay` prop to `modal`
- rename `PortalManager`'s `pageContent` prop to `children` and make it
  required, since a portal host doesn't render a page
- move the `collapsable` comment onto the prop it explains
- rewrite the `modal` prop documentation
A `Modal` is an overlay, so it always needs a `Portal` with `modal` set
to hide the content behind it. Render one itself instead of asking every
call site to wrap the modal and pass the prop.

BREAKING CHANGE: `Modal` and `Dialog` no longer need to be wrapped in a
`Portal`.
Every dialog now hides the content behind it, so the dedicated "Inert
background" example no longer has anything of its own to show.
`Dialog` renders itself in a `Portal`, so the examples no longer need to
wrap it in one.
Opening a modal now moves focus to its content, and closing it returns
focus to whatever had it before. On web the first tabbable element is
focused, since only the DOM can work out what that is; on native an
accessibility focus event is sent instead.

Focus waits for the fade in either direction: the content is not in the
accessibility tree until it is opaque, and the app behind the modal only
stops being inert once the modal has gone.

Adds `initialFocusRef` and `restoreFocusRef` so an app can name the
element to focus on open and the control to return to on close.
`useOverlayFocus` now returns `restoreFocus`, so the overlay decides when
focus goes back rather than having it fire from an unmount cleanup that
only ran once the content was already gone.

Restoring waits a tick, so the close render has removed background
inertness before the previous element is focused again. Reopening
mid-close keeps the element that first opened the overlay, rather than
capturing something inside the overlay that is still on its way out.

Each focus move now reports whether it landed, through a web half that
takes a DOM node and a native half that takes a ref, so an overlay that
cannot take focus no longer leaves the hook believing focus moved.
@github-actions

Copy link
Copy Markdown

Found potential problems with the pull request:

  • Screenshot or video evidence is missing. Make sure to include one if it affects the UI.

@konstmar konstmar changed the title feat(modal): move focus into the modal and hand it back on close feat(modal): move focus into the modal and restore it on close Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant