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.