Skip to content

fix(modal): make Modal accessible and honour its dismissal props - #5125

Open
konstmar wants to merge 10 commits into
callstack:mainfrom
konstmar:modal-a11y
Open

konstmar wants to merge 10 commits into
callstack:mainfrom
konstmar:modal-a11y

Conversation

@konstmar

@konstmar konstmar commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Motivation

This PR improves some aspects of Paper's Modal accessibility.

  • dismissableBackButton is now honoured on its own - hardware back read dismissable || dismissableBackButton, so dismissableBackButton={false} did nothing unless dismissable was also false
  • Adds aria-label prop to Modal. role prop should be added in this one instead refactor(dialog): MD3 updates, updated Dialog API #5072
  • The escape gesture (iOS) no longer closes a modal with dismissable={false}
  • Hides the backdrop/scrim from assistive technology with dismissable={false}

Related issue

Notion

Screenshots / Videos

Screen reader speech is shown on screen in each clip (VoiceOver caption panel / TalkBack "Display speech output").

Before (main) After
Escape gesture on dismissable={false} (iOS)
escape-gesture-ignores-dismissable-before.mp4
escape-gesture-ignores-dismissable-after.mp4
Hardware back on dismissableBackButton={false} (Android)
undismissable-back-button-before.mp4
undismissable-back-button-after.mp4

Test plan

yarn test, yarn lint, yarn typecheck pass. In the example app:

  1. Swipe through a dialog with a screen reader - focus skips the backdrop, tapping outside still dismisses.
  2. "Undismissable Dialog", escape gesture - stays open.
  3. "Undismissable back button" (new example, Android), hardware back - stays open.
  4. On web, focus a dialog - aria-label and role announced.

Konstantin Marushchak added 7 commits September 15, 2026 18:16
Test `dismissableBackButton` alone instead of `dismissable ||
dismissableBackButton`, which short-circuited on `dismissable` so the prop only
had an effect when `dismissable` was already `false`.
Add `role="dialog"` to the content `Surface` and accept an `aria-label`, so the
role and the name sit on the element that actually is the dialog.

Drop `aria-modal`: it was set on the full-screen wrapper rather than on the
dialog, and this component does not make the content behind the modal inert, so
claiming modality would have misdescribed it.
The scrim carries its own accessible name and closes the modal when it is
pressed, but `importantForAccessibility="no"` kept it out of the screen
reader's reach, so the one control that dismisses the modal was announced
to nobody.

Tie `accessible` to `dismissable` so the scrim is only announced when pressing
it does something. A non-dismissible modal would otherwise offer a "Close modal"
button that ignores every press.
`dismissable={false}` keeps the scrim from closing the modal, but the
iOS escape gesture closed it anyway, so a screen reader user could leave
a modal the interface was holding them in.
`aria-modal` was dropped from the component while reworking the modal's
accessibility, leaving nothing to scope assistive technology to the dialog.
On iOS it maps to `accessibilityViewIsModal`, which is the only mechanism
that takes effect there: `role` and `aria-label` are both ignored by
VoiceOver on native, so without it a screen reader could reach the content
behind an open modal.

Put it on the content `Surface` rather than the full-screen wrapper, so the
boundary matches the element that is the dialog.
The role was hardcoded to `dialog`, which suits most modals but not one that
interrupts the user with an urgent message. Accept a `role` prop, typed as
React Native's `Role` and defaulting to `dialog`, so `alertdialog` and the
other ARIA roles are reachable without forking the component.

Note that native platforms ignore this: React Native maps every dialog-ish
role to `UIAccessibilityTraitNone` on iOS, and TalkBack does not announce it
either. It carries on react-native-web, where the role is honoured.
The scrim is a visual affordance, not a control worth announcing. Exposing it
gave screen reader users a "Close modal" button that duplicates the escape
gesture and the back button, and on Android it was announced as disabled
whenever `dismissable` was `false`. Hide it with `aria-hidden`, which covers
both platforms: React Native maps it to `accessibilityElementsHidden` on iOS
and `importantForAccessibility="no-hide-descendants"` on Android.

With the scrim out of the accessibility tree its label can no longer be read
by anything, so `overlayAccessibilityLabel` is removed rather than left as a
prop that does nothing. Tests locate the scrim through `overlayTestID`, which
already existed for that purpose.

BREAKING CHANGE: `overlayAccessibilityLabel` is removed from `Modal`. The
scrim is no longer exposed to assistive technology, so the prop had no
remaining effect. Use `overlayTestID` to target the scrim in tests.

@k0ndee k0ndee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Now that Modal accepts aria-label/role, Dialog doesn't forward either to it - its Props type has no aria-label/role field at all. So after this merges, <Dialog> usage still has no accessible name. Will that be wired up in the Dialog-adoption follow-up task, or should it land here since the plumbing already exists?

Comment thread src/components/Modal.tsx Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

dismissable isn't read inside this effect anymore after the fix, You can drop it from the deps array

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.

@k0ndee updated

Comment thread src/components/Modal.tsx
aria-live="polite"
style={StyleSheet.absoluteFill}
onAccessibilityEscape={onDismissCallback}
onAccessibilityEscape={dismissable ? onDismissCallback : undefined}

@k0ndee k0ndee Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Modal.tsx has missing focus-related code that is one of the acceptance criteria for this ticket.

  • Initial focus - nothing moves focus into the Surface when visible becomes true
  • Focus trap - nothing intercepts Tab to keep focus inside the modal while open; a keyboard or switch-control user can tab straight into the content behind it
  • Focus restore - nothing captures the pre-open focus target or restores it in onDismissCallback on close

Given the ticket calls this out as the hard, VoiceOver/TalkBack/keyboard/switch-control-verified part of the work, will there be another follow-up PR for this, or should it be included here? @satya164 what do You think about it?

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.

There will be a follow-up PR with initial focus / restore focus.
Regarding focus trap - #5126 this one covers it.

Comment thread src/components/Modal.tsx
>
<AnimatedPressable
aria-label={overlayAccessibilityLabel}
role="button"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The role="button" is now a no-op due to the aria-hidden prop added to AnimatedPressable, it can be removed now.

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.

@k0ndee updated

The scrim is hidden from assistive technology, so the role it
declared no longer reaches anyone. The hardware back effect reads
only dismissableBackButton now, so dismissable is no longer a
dependency of it.
@konstmar

Copy link
Copy Markdown
Contributor Author

Now that Modal accepts aria-label/role, Dialog doesn't forward either to it - its Props type has no aria-label/role field at all. So after this merges, <Dialog> usage still has no accessible name. Will that be wired up in the Dialog-adoption follow-up task, or should it land here since the plumbing already exists?

@k0ndee I believe there should be an adoption task for Dialog, but I can add this small change here instead.

@k0ndee

k0ndee commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Now that Modal accepts aria-label/role, Dialog doesn't forward either to it - its Props type has no aria-label/role field at all. So after this merges, <Dialog> usage still has no accessible name. Will that be wired up in the Dialog-adoption follow-up task, or should it land here since the plumbing already exists?

@k0ndee I believe there should be an adoption task for Dialog, but I can add this small change here instead.

Actually, it looks like #5072 already forwards aria-label from Dialog to Modal, so that half is covered there. role isn't wired up in #5072 though, and its Modal usage still predates this PR, so it'll need a rebase against this once it merges regardless. Given that, probably cleanest to leave role out of this PR and pick it up as part of that rebase in #5072 instead, so it doesn't end up half-done in one PR and forgotten in the other. @kachmashk, flagging so it's on your radar once this lands.

@k0ndee k0ndee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM! @satya164

The surface stays a dialog, but the prop lands together with the Dialog
wiring in callstack#5072 rather than half-done here.
@github-actions

Copy link
Copy Markdown

Found potential problems with the pull request:

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

Comment thread src/components/Modal.tsx Outdated
aria-label={overlayAccessibilityLabel}
role="button"
disabled={!dismissable}
aria-hidden

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.

Why is overlay hidden from a11y with aria-hidden?

If a dialog doesn't have a button to close itself, doesn't mean screen reader users and keyboard users won't be able to close it at all as compared to mouse users?

@konstmar konstmar Sep 17, 2026

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.

@satya164 I made the decision to not make it accessible based on a few things:

  1. It was not accessible on android in the first place (via importantForAccessibility)
  2. The task suggested removing the label since it was not accessible
  3. https://m3.material.io/components/dialogs/guidelines 's anatomy shows that Dialog should have actions.
  4. https://m3.material.io/components/dialogs/accessibility says the initial focus should land on the first interactive element within dialog, not on scrim. And the scrim is just a visual dimmer.
  5. https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ :
    • "All elements required to operate the dialog are descendants of the element that has role dialog."
    • "Tab and Shift + Tab do not move focus outside the dialog"
    • "It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button."

The screen reader users and keyboard users can still use Escape/Back to initiate close.

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.

Dialog should have actions.

It should, but material design guidelines are still just guidelines. They are not requirements the users must implement. Also this affects modals in general, not just dialogs.

says the initial focus should land on the first interactive element within dialog, not on scrim.

What's scrim? Overlay? That sounds right, but it could be achieved without making the overlay inaccessible.

And the scrim is just a visual dimmer.

But the overlay is not just a visual dimmer here, users can already interact with it using a mouse, which makes it an interactive element.

Every interactive element should have equivalent for screen readers. If it's not the overlay itself, then there needs to be a screen reader specific affordance to close the modal like a mouse user would.

"All elements required to operate the dialog are descendants of the element that has role dialog."
"It is strongly recommended that the tab sequence of all dialogs include a visible element with role button that closes the dialog, such as a close icon or cancel button."

Still, the problem is these are guidelines and recommendations. While we can implement the recommendations in the library itself, the user may not. We should still provide an accessible experience by default.

Prior design to this would be what React Aria does. It hides the overlay from a11y, but also provides a screen-reader only affordance.

Following is from Codex:

When outside-click dismissal is enabled, React Aria automatically adds a visually hidden dismiss button inside the modal. Screen reader users can find and activate it even if the app doesn't provide a close button. It has a localized label and tabIndex={-1}, so it doesn't add an extra Tab stop. Keyboard users can dismiss with Escape. (Modal implementation, dismiss button)
We could follow the same approach. If we hide the overlay from a11y, we should provide an equivalent dismiss button for screen reader users whenever clicking the overlay can close the modal. We also need Escape handling on web, which is currently missing.

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.

@satya164 Got it. I'll revert the changes and make overlay accessible again when modal is dismissable.

Comment thread src/components/Modal.tsx
* Accessibility label for the overlay. This is read by the screen reader when the user taps outside the modal.
* Accessible name for the modal.
*/
overlayAccessibilityLabel?: string;

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.

with overlayAccessibilityLabel removed, how can user customize aria label for the overlay or access it in tests (there is test id, but better practice is to use a11y, same way your users will)?

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.

@satya164 please check my comment above. It will not be needed if we move forward with not making it accessible

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.

Restored the label

@konstmar
konstmar requested a review from satya164 September 17, 2026 13:16
@konstmar konstmar changed the title fix(modal)!: make Modal accessible and honour its dismissal props fix(modal): make Modal accessible and honour its dismissal props Sep 17, 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.

3 participants