Accessibility in React Native
There is no DOM and no semantic HTML: a View is just a box, so the role,
name and state that VoiceOver (iOS) and TalkBack (Android) read come
from props, mapped to the native accessibility APIs. A Pressable is
focusable but has no role until you set one, so give every control a role
and a label (role, aria-label), group related content into one element,
hide decorative parts, keep touch targets large, let text scale with
the system font size, and respect Reduce Motion. Many props work on one
platform only, so test with both screen readers.
Applies to: React Native 0.88, Expo SDK 58, Reanimated 4.
Details
WCAG, contrast, the ARIA rules and the laws (the European Accessibility Act also covers e-commerce, banking and transport apps) are the same as on the web and are not repeated here, see Web Accessibility.
Main Props
| Prop | aria-* form | What it gives | Platform |
|---|---|---|---|
accessible | — | Makes the view one focus stop; its children are not | Both |
accessibilityLabel | aria-label | The name; without it, the text of the children is read | Both |
accessibilityHint | — | What happens on activation: "Opens the cart" | iOS: only if hints are on in VoiceOver settings; Android: always |
role / accessibilityRole | — | button, link, heading / header, switch, tab, … | Both; role wins if both are set |
accessibilityState | aria-disabled, -selected, -checked, -expanded, -busy | State; checked can be 'mixed' | Both |
accessibilityValue | aria-valuemin, -max, -now, -text | Value of a slider or a progress bar | Both |
accessibilityLiveRegion | aria-live | Announces content changes | Android only |
accessibilityViewIsModal | aria-modal | Screen reader ignores the sibling views | iOS only |
importantForAccessibility, accessibilityElementsHidden | aria-hidden (both platforms) | Hide a subtree from the screen reader | Android / iOS |
accessibilityLabelledBy | aria-labelledby | Takes the label from another view by nativeID | Android only |
accessibilityActions + onAccessibilityAction | — | Custom actions in the screen reader menu | Both |
accessibilityLanguage | — | Language the label is read in | iOS only |
-
Neither the
role/aria-*props nor theaccessibilityRole/accessibility*props are deprecated, and the docs do not prefer one.roleandaria-*look like the web, butaria-liveandaria-labelledbystill work only on Android andaria-modalonly on iOS. -
The
Checked on iOS 27, · field note Checked on Android 16, · field noteaccessibility*props map directly to the native accessibility APIs; thearia-*aliases came later, and in current versions not every core component passes all of them on, without a warning. Fixes are under way. If anaria-*prop has no effect, use itsaccessibility*form. -
rolevalues are web-like (heading,img,slider),accessibilityRolevalues are the older ones (header,image,adjustable). For navigation inside the app usebutton, notlink. -
On iOS some
rolevalues give the element no trait:role="slider"is not adjustable,role="searchbox"is not a search field. Use theaccessibilityRolevalue there (adjustable,search). -
Pressable,Touchable*,TextInputandSwitchare focus stops by default; a plainViewis not.TextwithonPressgets thelinkrole on its own.
Grouping and Hiding
- A
Pressableis one element: the label is built from allTextinside it joined with spaces. A card with an image, a title and a price inside aPressableis read once; inside a plainViewthe title and the price are separate stops, and an image withoutaltis skipped. accessibleon a container does the same for non-pressable content, for example a row "Rating 4.5, 120 reviews". On iOS, VoiceOver cannot focus the children of such an element (TalkBack still reaches nested buttons), so do not group a container that has its own buttons.- An icon-only button needs a label and a role, or it is read as just "button" or as nothing useful.
- Images: an informative one gets
alt; a decorative one inside a labeled element is already covered, on its own it getsaria-hidden. experimental_accessibilityOrder(an array ofnativeIDs) sets the focus order; it is experimental, and by default the order follows the layout.
Form Fields
- There is no
<label for>: put the label on theTextInputitself (aria-labeloraccessibilityLabel).aria-labelledbypointing to a visibleTextworks only on Android. - A
placeholderis not a label: it disappears once the user types. - A validation error must be heard, not only seen: announce it, or add it to the field's label or hint while it is shown.
Modals and Overlays
-
Checked on iOS 27, · field noteaccessibilityViewIsModalmakes VoiceOver ignore the siblings of the view: put the dialog and the content it covers at the same level, or the background stays reachable. Prefer it toaria-modalon aView. -
The
Modalcomponent is presented as a separate native layer (a view controller on iOS, a dialog window on Android), so the screen under it is not reachable, withtransparenttoo. -
A custom bottom sheet or overlay on Android has no modal prop: hide the background with
aria-hidden(importantForAccessibility="no-hide-descendants") and move focus into the sheet.
Focus and Announcements
| Task | iOS | Android |
|---|---|---|
| Move focus to a view | AccessibilityInfo.sendAccessibilityEvent(ref, 'focus') | The same |
| "Item added" without moving focus | AccessibilityInfo.announceForAccessibility('…') | A live region: accessibilityLiveRegion="polite" on the Text |
| Is a screen reader on? | isScreenReaderEnabled(), screenReaderChanged event | The same |
| Give more time before a toast or a hint hides | — | getRecommendedTimeoutMillis() |
-
Live regions do not work on iOS: UIKit has no such concept, VoiceOver speaks only when the app posts an announcement, which is what
announceForAccessibilitydoes. It works on Android too (still on Android 16), but the Android API behind it is deprecated since Android 16, so a live region is the safer choice there.announceForAccessibilityWithOptionswith{ queue: true }waits for the current speech on iOS; TalkBack plays announcements one after another anyway. -
A live region on Android:
Checked on Android 16, · field noteaccessibilityLiveRegionon theText, oraria-liveon aViewaround it withcollapsable={false}, so that view flattening does not remove the view. -
After navigation in a React Navigation 7 native stack, VoiceOver focuses the first element of the new screen and TalkBack the header's back button. Going back, TalkBack returns focus to the button that opened the screen and reads the screen title and the button; VoiceOver puts focus on the back button of the previous screen, not on that button, and says nothing. To land on the title, move focus in
useFocusEffectwithsendAccessibilityEvent(titleRef.current, 'focus')(it replaces the deprecatedsetAccessibilityFocus). The view that gets focus must beaccessible. -
Swipe-to-delete, drag and drop, long press menus: a screen reader user cannot perform the gesture, so add the same actions to
accessibilityActions({ name: 'delete', label: 'Delete' }) and handle them inonAccessibilityAction. Standard names:activate,increment,decrementon both platforms;magicTap,escapeon iOS;longpress,expand,collapseon Android. -
On iOS a VoiceOver double tap does not reach
onAccessibilityActionasactivate: it callsonPress, andactivateshows up as a separate custom action. Put the main action inonPress, and handle the two-finger scrub withonAccessibilityEscaperather than theescapeaction.
Font Scaling
TextandTextInputfollow the system font size (Dynamic Type on iOS, "Font size" on Android):allowFontScalingistrueby default. Do not turn it off for the whole app: users who set large text need it.maxFontSizeMultipliercaps the scale where the layout really cannot grow (a tab bar label, a badge);0means no cap.- Layouts must grow with the text: no fixed heights on rows and buttons, let
text wrap (
numberOfLinesonly where truncation is fine), switch a row to a column at large font scales. The current scale isuseWindowDimensions().fontScaleorPixelRatio.getFontScale(), see StyleSheet. - Test with the largest size, including the extra accessibility sizes on iOS.
Touch Targets
- Minimum size: 44 × 44 pt on iOS (Apple HIG), 48 × 48 dp on Android (Material); WCAG 2.2 asks for 24 × 24.
- A small icon gets a bigger target with
hitSloponPressable(see Gestures) or with padding andminHeight.
Reduce Motion
AccessibilityInfo.isReduceMotionEnabled()and thereduceMotionChangedevent: replace large movement, parallax and zoom with a fade or no animation.- Reanimated: every animation has
reduceMotion: ReduceMotion.Systemby default, so it jumps straight to its final value when the setting is on.useReducedMotion()reads the setting synchronously but only once, at startup; it does not re-render on change.<ReducedMotionConfig mode={…} />overrides this for every animation in the app. KeepReduceMotion.Neveron animations that carry meaning, such as a progress bar. - The core
AnimatedAPI does not check the setting: read it yourself, see Animated.
Testing
- By hand: VoiceOver and TalkBack on a device, swiping through every screen; the largest font size; Reduce Motion on.
- Accessibility Inspector (Xcode) on the iOS Simulator: shows label, role and traits and runs an audit. Accessibility Scanner (Android app): suggests fixes for target size, contrast and labels.
- React Native Testing Library:
getByRole('button', { name: 'Add to cart' })fails if the element loses its role, its label or its accessibility (aViewwith a role but withoutaccessibledoes not match). Hidden elements are excluded by default. See Testing. eslint-plugin-react-native-a11ycatches missing labels, but has not had a release since 2024.
Example
import { Image, Pressable, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
type Product = { name: string; price: string; imageUrl: string };
export function ProductCard({
product,
onOpen,
onFavorite,
}: {
product: Product;
onOpen: () => void;
onFavorite: () => void;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{/* Title (read as "link") and price are separate stops, the image is
skipped; the icon has no name and no role */}
<View>
<Image
source={{ uri: product.imageUrl }}
style={{ width: 64, height: 64 }}
/>
<Text onPress={onOpen}>{product.name}</Text>
<Text>{product.price}</Text>
</View>
<Pressable onPress={onFavorite}>
<Ionicons name="heart-outline" size={20} />
</Pressable>
{/* One stop: "Running shoes, $89, button" */}
<Pressable onPress={onOpen} role="button" style={{ flex: 1 }}>
<Image
source={{ uri: product.imageUrl }}
style={{ width: 64, height: 64 }}
/>
<Text>{product.name}</Text>
<Text>{product.price}</Text>
</Pressable>
<Pressable
onPress={onFavorite}
role="button"
aria-label={`Add ${product.name} to favorites`}
hitSlop={12} // 20 + 2 × 12 = 44 pt
>
<Ionicons name="heart-outline" size={20} />
</Pressable>
</View>
);
}
An announcement and a custom action
import { AccessibilityInfo, Pressable, Text } from 'react-native';
export function CartRow({
title,
onOpen,
onDelete,
}: {
title: string;
onOpen: () => void;
onDelete: () => void;
}) {
function remove() {
onDelete();
// Both platforms; on Android a live region on the cart counter
// (accessibilityLiveRegion) is the alternative to the deprecated API
AccessibilityInfo.announceForAccessibility(`${title} removed`);
}
return (
// Swipe-to-delete is not available to a screen reader: offer it as an action
<Pressable
onPress={onOpen}
role="button"
accessibilityActions={[{ name: 'delete', label: 'Remove from cart' }]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'delete') remove();
}}
>
<Text>{title}</Text>
</Pressable>
);
}
Follow-up Questions
- What is the difference between
accessibilityLabelandaccessibilityHint? The label is what the element is ("Delete"); the hint is what happens ("Removes the message"). iOS users can turn hints off, so never put the only important information there. - How do you make a custom slider accessible?
accessibilityRole="adjustable"(notrole="slider", see above),accessibilityValuewithmin,maxandnow, and theincrement/decrementactions, which VoiceOver triggers with a swipe up or down. - What is Magic Tap? A two-finger double tap in VoiceOver for the main action of
the screen (play / pause, answer a call). React Native exposes it as the
onMagicTapprop and themagicTapaction inaccessibilityActions, but in current versionsonMagicTapdoes not fire: the gesture goes to the system, which plays or pauses music. Do not rely on Magic Tap; keep the action reachable as a normal button. accessible={false}oraria-hidden: what is the difference? The first only stops the view from being a focus stop, its children are still read; the second hides the whole subtree.- How do you know a screen reader is on, and should the UI change? Use
isScreenReaderEnabled(); change only what cannot work otherwise, such as auto-hiding controls or a carousel that advances on its own.
Common Mistakes
- Icon buttons without a label: read as "button" or as the icon name.
- A clickable
ViewwithonTouchEndinstead of aPressable: no role, not focusable for the screen reader. - Fixed heights and
allowFontScaling={false}everywhere to keep the design. - Testing only on the iOS Simulator: TalkBack and VoiceOver differ, and many props are one-platform only.
aria-liveoraria-labelledbyused and tested only on Android: they do nothing on iOS.- Trusting an
aria-*prop on a core component without checking it with a screen reader: some components drop some of them without a warning. accessibleon a card that has its own buttons: VoiceOver cannot reach them.- Gestures without an alternative: swipe actions, drag to reorder.
- Using
linkfor in-app navigation, or roles without the matching state (switchwithoutchecked, a tab withoutselected).