Skip to content

Conversation

@coolsoftwaretyler
Copy link

@coolsoftwaretyler coolsoftwaretyler commented Oct 26, 2025

Summary:

This is an initial idea for how we might solve a longstanding accessibility pain point on iOS: #24515.

Right now, if you write some React Native code like this:

function AppContent() {
  return (
    <View style={styles.container}>
      <Pressable
        style={styles.outerPressable}
        onPress={() => Alert.alert('outer pressable')}
        role="button"
        accessibilityLabel="outer pressable"
      >
        <Text style={styles.outerText}>Outer Pressable</Text>
        <Pressable
          style={styles.innerPressable}
          onPress={() => Alert.alert('inner pressable')}
          role="button"
          accessibilityLabel="inner pressable"
        >
          <Text style={styles.innerText}>Inner Pressable</Text>
        </Pressable>
      </Pressable>
    </View>
  );
}

One of the Pressable items will be inaccessible via VoiceOver on iOS. You can't select and interact with both. It works fine on TalkBack on Android, which discovers them independent of one another.

You can fix this manually by using the accessibilityActions and onAccessibilityAction props, like this:

diff --git a/App.tsx b/App.tsx
index faebed4..29c2575 100644
--- a/App.tsx
+++ b/App.tsx
@@ -20,19 +20,42 @@ function App() {
 }
 
 function AppContent() {
+  const handleOuterPress = () => {
+    Alert.alert('outer pressable');
+  };
+
+  const handleInnerPress = () => {
+    Alert.alert('inner pressable');
+  };
+
+  const handleAccessibilityAction = (event: { nativeEvent: { actionName: string } }) => {
+    const { actionName } = event.nativeEvent;
+    if (actionName === 'outer') {
+      handleOuterPress();
+    } else if (actionName === 'inner') {
+      handleInnerPress();
+    }
+  };
+
   return (
     <View style={styles.container}>
       <Pressable
         style={styles.outerPressable}
-        onPress={() => Alert.alert('outer pressable')}
-        role="button"
-        accessibilityLabel="outer pressable"
+        onPress={handleOuterPress}
+        accessible={true}
+        accessibilityLabel="Nested Pressables"
+        accessibilityActions={[
+          { name: 'outer', label: 'Activate outer pressable' },
+          { name: 'inner', label: 'Activate inner pressable' },
+        ]}
+        onAccessibilityAction={handleAccessibilityAction}
       >
         <Text style={styles.outerText}>Outer Pressable</Text>
         <Pressable
           style={styles.innerPressable}
-          onPress={() => Alert.alert('inner pressable')}
-          role="button"
+          onPress={handleInnerPress}
+          accessible={false}
+          importantForAccessibility="no-hide-descendants"
         >
           <Text style={styles.innerText}>Inner Pressable</Text>
         </Pressable>

This works OK, but you have to know to do it, and then take the time to do it well. A lot of developers don't know about this issue, and end up degrading their user's experience because of it. Overall, it means React Native apps start on the back foot as far as accessibility goes. I think we could improve the experience of React Native apps broadly by offering a better built-in behavior.

iOS does have a concept of accessibility containers. I think we can leverage that to improve the experience. Thanks to @lindboe for teaching me about accessibility containers (and about this problem in general).

My initial proposal is this:

  1. Add a new accessibilityContainer, iOS-only prop.
  2. Set it to false by default, to preserve backwards compatibility, and to make this behavior opt-in. That way, we don't make every single view try to collect all of its children, unless a developer opts in to it (for now). And we also avoid the scenario where this breaks people who are using accessibilityActions (I'm not sure how these props might conflict).
  3. When a view sets accessibilityContainer to a truthy value, it makes child interactive elements independently accessible by adding them to _accessibilityElements, along with a proxy for the container itself (so we can toss this prop on a wrapper Pressable without requiring additional wrapper View components or anything)

In an ideal world, I think this prop should be true by default, so devs don't have to know to do this at all, and they'd get a better default experience. But I'm not sure what the performance implications are for it since we have to parse all the subviews. Maybe it's not too bad, but I mostly want to get the conversation started, I'm not married to a given implementation.

I hope a change like this might make the default React Native experience more accessible for many users.

Changelog:

[IOS] [ADDED] - accessibilityContainer prop to allow easier accessibility grouping

Test Plan:

  1. Pull down this branch and build the rn-tester app with cd packages/rn-tester && yarn prepare-ios
  2. Open it up. The best way to do this is on a real device since VoiceOver has limitations on the simulator. But the accessibility inspector tool does pick up these changes.
  3. Go to the Pressable component demo
  4. Scroll to the bottom, I added a new nested pressable example
  5. Tap the outer and inner pressable. You'll see they increment separate counters.
  6. Turn on VoiceOver or check the accessibility inspector
  7. Attempt to use both the outer and inner pressables. They should both work and increment their separate counters.

Videos

Physical device/VoiceOver

Here's the fixed behavior with accessibilityContainer={true} on a physical device:

video1110878829.mp4

And here's how it behaves when the prop is false (current behavior):

video1009449806.mp4

Simulator/Accessibility Inspector

Here's a video with a simulator, you can see the change when I toggle the prop:

Screen.Recording.2025-10-25.at.8.35.15.PM.mov

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 26, 2025
@facebook-github-bot facebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 26, 2025
@jorge-cab
Copy link
Contributor

Hey @coolsoftwaretyler! Thank you for looking into this. Accessibility in React Native is a pretty hard problem!

This issue you are mentioning with children of accessibilityContainers not being accessible I think is solved by a prop that's currently experimental experimental_accessibilityOrder which @joevilches and I worked on earlier this year.

With this prop you can set in which order the children of a given View should be visited by VoiceOver/Talkback. This maps (almost?) 1:1 with the accessibilityElements API on iOS and works cross-platform with Android.

I think this proposal is interesting since it essentially let's us "opt-out" a view of being an accessibilityContainer, but other than it being a more convenient way to do this when you want to preserve the default ax order, I don't think we'd want to go this way since accessibilityOrder already addresses the issue, and it also maps 1:1 to the native API.

I'd recommend checking out experimental_accessibilityOrder and let us know if you have any feedback on it! Specially if you find some accessibility use case where experimental_accessibilityOrder is insufficient.

Happy to discuss this more though!

@coolsoftwaretyler
Copy link
Author

Hey @jorge-cab - that's awesome! I will definitely check it out this week. Thanks for the feedback. Glad to see React Native moving forward with a solution here.

@coolsoftwaretyler
Copy link
Author

coolsoftwaretyler commented Oct 27, 2025

Hey @jorge-cab - is there any documentation on how I could use experimental_accessibilityOrder for nested pressables? It doesn't seem to solve the same problem as this PR, but perhaps I'm using it incorrectly.

I tried setting 0 on the outer, and 1 on the inner, using my new example from this PR:

          <View style={styles.row}>
            <Pressable
              accessibilityLabel="Outer card button"
              accessibilityRole="button"
              experimental_accessibilityOrder={0}
              onPress={() => setOuterPressCount(outerPressCount + 1)}
              style={{
                backgroundColor: '#f9c2ff',
                padding: 16,
                borderRadius: 8,
              }}>
              <Text>Outer Pressable</Text>
              <Pressable
                accessibilityLabel="Inner button"
                accessibilityRole="button"
                experimental_accessibilityOrder={1}
                onPress={() => setInnerPressCount(innerPressCount + 1)}
                style={{
                  backgroundColor: '#61dafb',
                  padding: 12,
                  marginTop: 8,
                  borderRadius: 6,
                }}>
                <Text>Inner Pressable</Text>
              </Pressable>
            </Pressable>
          </View>

But I'm not able to focus the inner pressable. Here's what I'm seeing with accessibility inspector:

Screen.Recording.2025-10-27.at.9.08.14.AM.mov

And I've got the same issues on a physical device (in this example I keep swiping to the right, but never get to focus the inner pressable):

video1897589165.mp4

@jorge-cab
Copy link
Contributor

Oh yeah sorry about that, you can check out the example on RNTester here

function AccessibilityOrderExample(): React.Node {

You need to set a nativeID on the views you want to add to experimental_accessibilityOrder and then reference that in the array. We still don't have any official docs since the behavior of this might still change.

@joevilches
Copy link
Contributor

@coolsoftwaretyler @jorge-cab I actually did add documentation recently: https://reactnative.dev/docs/view#experimental_accessibilityorder. This is an experimental API, so it might be disabled for you (and is by default unless you turn it on). If that is the case, https://reactnative.dev/docs/next/releases/release-levels should help you figure out how to enable it.

@joevilches
Copy link
Contributor

ok read through the rest of the conversations here. I agree with @jorge-cab that accessibilityOrder should be what you use to fix this. I will say, it is not intended to be used this way but it is a way to workaround the issue currently. Ideally we have some API similar to what you are proposing but its not that easy. Some things you need to consider in doing this:

  • You cannot add the children to accessibilityElements in document order. VoiceOver uses layout to decide what order to focus things in. So if one of those children does something with layout that changes its position drastically, it will likely be focused in the wrong order
  • Overriding the accessibilityElements getter is dangerous and can lead to stall crashes on iOS. I could never repro this directly, but we had logs supporting this and when I changed it to use the setter self.accessibilityElements = ... it started working fine.
  • A deeply nested accessibility element would get ignored by the container, although that would be an easier fix of just going through the entire subtree when you create accessibilityElements
  • We would need to think how this works with experimental_accessibilityElements right now they clobber each other.

The hardest part here is getting a focus order which matches iOS, since focus order is not always the most intuitive (you can take a look at how android does this in AOSP if you dare 😄). That being said, we could just change React Native to have its own axioms revolving focus order, and just do this ourselves with the layout information available in the shadow tree. Then rolling that out would be a nightmare - we would likely change quite a few app's focus order of some elements. If we keep this as opt in then we have a situation where sometimes, the UI tree focus's elements in a certain way, and sometimes in a different way.

Its not the worst, and not impossible, just quite challenging to roll out safely and make a case for that work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants