Skip to content

feat(portal)!: hide content below an overlay from assistive technology - #5126

Open
konstmar wants to merge 7 commits into
callstack:mainfrom
konstmar:portal-inert-background
Open

konstmar wants to merge 7 commits into
callstack:mainfrom
konstmar:portal-inert-background

Conversation

@konstmar

@konstmar konstmar commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Motivation

With a dialog open, a screen reader can still wander into the app content behind it.

Adds an opt-in modal prop to Portal. A portal marked as a modal hides every layer below it - the app content and any portal mounted earlier - from assistive technology and from the web focus order. Portals mounted on top stay reachable, so a menu or snackbar above a dialog still works.

Modal now renders itself in a Portal with modal prop tied to visible. Other overlay components will adopt it separately.

Breaking:

Modal and Dialog render themselves in a Portal, so they now need a Portal.Host above them in the tree.

Related issue

Notion

Screenshots / Videos

inert-background-android-after.mp4
inert-background-ios-after.mp4

Test plan

yarn test, yarn lint, yarn typecheck pass. Six new Portal tests.

On a device, open any dialog in the example app and keep swiping past its last button with a screen reader, or hold Tab on web: focus stays inside the dialog, and the screen behind it is reachable again once it closes.

Konstantin Marushchak added 3 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.
Comment thread src/components/Portal/PortalManager.tsx Outdated
import OverlayLayer from './OverlayLayer';

type Props = {
pageContent?: React.ReactNode;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pageContent is misleading name as a portal provider doesn't render a page. it should just be content or children.

it should also not be optional.

Suggested change
pageContent?: React.ReactNode;
children: React.ReactNode;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

Comment thread src/components/Portal/Portal.tsx Outdated
Comment on lines +19 to +28
/**
* Whether this portal hides everything below it -- the app content and any
* portal mounted before it -- from screen readers and the focus order.
* Portals mounted after it stay reachable.
*
* Tie it to whether the overlay is open rather than to how long it stays
* painted: a layer gives the screen back the moment it starts closing, so
* what is underneath is reachable again while the overlay fades out.
*/
overlay?: boolean;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modal maybe a better name instead overlay

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to modal

Comment thread src/components/Portal/PortalManager.tsx Outdated

return (
<>
{/* Need collapsable=false here to clip the elevations, otherwise they appear above Portal components */}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move the comment to collapsable like the other one

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

Comment on lines +19 to +27
/**
* Whether this portal hides everything below it -- the app content and any
* portal mounted before it -- from screen readers and the focus order.
* Portals mounted after it stay reachable.
*
* Tie it to whether the overlay is open rather than to how long it stays
* painted: a layer gives the screen back the moment it starts closing, so
* what is underneath is reachable again while the overlay fades out.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please write the comment by hand. don't use claude for documentation.

Suggested change
/**
* Whether this portal hides everything below it -- the app content and any
* portal mounted before it -- from screen readers and the focus order.
* Portals mounted after it stay reachable.
*
* Tie it to whether the overlay is open rather than to how long it stays
* painted: a layer gives the screen back the moment it starts closing, so
* what is underneath is reachable again while the overlay fades out.
*/
/**
* Whether the portal hides items below it from screen readers and focus order.
*
* Ensure it's set to true only when the modal is open.
*/

Comment on lines +1 to +31
import { Button, Portal, Dialog, Palette } from 'react-native-paper';

import { TextComponent } from './DialogTextComponent';

const DialogWithOverlay = ({
visible,
close,
}: {
visible: boolean;
close: () => void;
}) => (
<Portal overlay={visible}>
<Dialog onDismiss={close} visible={visible}>
<Dialog.Title>Alert</Dialog.Title>
<Dialog.Content>
<TextComponent>
While this dialog is open, everything behind it is hidden from screen
readers and skipped by the focus order!
</TextComponent>
</Dialog.Content>
<Dialog.Actions>
<Button textColor={Palette.tertiary50} disabled>
Disagree
</Button>
<Button onPress={close}>Agree</Button>
</Dialog.Actions>
</Dialog>
</Portal>
);

export default DialogWithOverlay;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a separate example is needed. all dialogs need to be used this way. update all the existing examples

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Now Modals render the Portal inside them

Konstantin Marushchak added 4 commits September 17, 2026 15:17
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.
@github-actions

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.

@konstmar konstmar changed the title feat(portal): hide content below an overlay from assistive technology feat(portal)!: hide content below an overlay from assistive technology Sep 17, 2026
@konstmar
konstmar requested a review from satya164 September 17, 2026 13:58
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.

2 participants