React Native – Accessibility

August 6, 20263 min readUpdated 8/24/2026

On the web, a <button> announces itself as a button, reports when it is disabled and is reachable by keyboard — none of which you asked for. In React Native a Pressable is a View that responds to touch, and it announces nothing.

Everything a screen reader needs is something you declare. That is more work and it is also clearer, because you can see exactly what you promised.

The three props

      accessibilityRole="button"
      accessibilityLabel={accessibilityLabel ?? title}
      accessibilityState={{ disabled: isDisabled, selected }}
      hitSlop={8}

accessibilityRole is what makes VoiceOver say "button" rather than reading the label as loose text. The useful ones: button, link, header, image, alert, checkbox, radio, switch, search.

accessibilityLabel is what gets read. Defaulting it to the visible title is right — a label that disagrees with the text on screen is worse than none, because voice control users say what they see.

accessibilityState carries disabled, selected, checked, expanded and busy. Without it a disabled button is announced as a perfectly ordinary button that happens not to work.

Controls you had to draw yourself

This is where it matters most. There is no <input type="radio">, so a radio is a circle with a smaller circle inside — and nothing about that shape tells a screen reader what it is:

          <Pressable
            key={segment.value}
            onPress={() => onChange(segment.value)}
            accessibilityRole="radio"
            accessibilityState={{ checked: active }}
            accessibilityLabel={segment.label}
            testID={testIDPrefix ? `${testIDPrefix}-${segment.value}` : undefined}
            style={[styles.segment, active && styles.segmentActive]}
          >

With those two props VoiceOver announces "Delivery, selected, one of two". Without them it reads two unrelated buttons and the user cannot tell which is active — the information was carried entirely by a background colour.

Wrap the group in accessibilityRole="radiogroup" and the relationship is complete. The same reasoning applies to any control you drew: checkboxes, toggles, star ratings.

Grouping, and the trap inside it

accessible on a View merges its children into a single element, so a two-line status message is one announcement rather than two stops:

      <View accessible accessibilityRole="alert">
        <Text variant="bodyStrong" tone="danger">
          Something went wrong
        </Text>
        <Text variant="caption" tone="muted" style={styles.caption}>
          {message}
        </Text>
      </View>

⚠️ An accessible container hides its children from the accessibility tree on iOS. Put a button inside one and it becomes unreachable — the classic way this pattern goes wrong. In the demo app the retry button is deliberately a sibling of that View, not a child of it.

The rule: group text, never group anything interactive.

Things that are invisible by default

A spinner. An ActivityIndicator conveys "wait" purely visually:

      <ActivityIndicator size="large" color={theme.colors.primary} accessibilityLabel={label} />

Without a label, a screen reader user gets silence and no idea anything is happening.

Decorative content. The reverse problem. An emoji used as an icon, or an image that repeats the text beside it, should be hidden: accessibilityElementsHidden on iOS and importantForAccessibility="no" on Android. Both, because they are platform-specific — which is easy to half-do.

Something that changed on screen. A toast appears without any interaction, so nothing prompts the reader to look. accessibilityLiveRegion="polite" on Android and accessibilityRole="alert" announce it when it arrives.

Touch targets

hitSlop expands the tappable area without changing the layout. Apple asks for 44dp and Android for 48dp, and a small chip or a "✕" glyph is usually smaller than both.

This is the accessibility fix that helps everyone — nobody enjoys missing a button — and it costs one prop. The demo app puts it on every icon-sized control.

Labels that distinguish

A list of ten quantity steppers all labelled "Increase" is unusable: the reader hears the same thing ten times with no way to tell which row it is on. Include the item:

        accessibilityLabel={`Decrease quantity of ${itemName}`}

The same applies to "Delete", "Edit" and "Remove" in any repeated row.

Testing it

Turn VoiceOver on and use your app for two minutes. Settings → Accessibility → VoiceOver on iOS, TalkBack on Android. Nothing else finds these problems as fast, and the first run is always educational.

Then automate what you can. Querying by accessibility label in tests — getByLabelText in React Native Testing Library — means the tests exercise the same information a screen reader gets, so a missing label breaks a test rather than only a person.

⚠️ One thing the web preview will not show you: react-native-web does not map accessibilityState onto aria-* attributes. A radio's checked state is correct on device and invisible in the DOM, so do not conclude from a browser inspection that it is missing.

What is next

Performance — two threads, and which one your code is on.