π¦Teaching React Native to Talk
Introduction
I've been deep in React Native accessibility recently and figured I should write it up while the content's fresh. The good news is that React Native gives you almost everything you need for VoiceOver and TalkBack out of the box, and most of the APIs are the same on both platforms.
If you've read my Web Accessibility article, a lot of the same principles carry over. The core props (accessibilityRole, accessibilityLabel, accessibilityState) work the same on both platforms. Where iOS and Android genuinely differ, I'll call it out.
The examples throughout use a small League of Legends champion browser demo I put together. It has enough going on (cards, modals, filters) to cover most of the patterns here.
Roles, Labels & Hints
This is the bread and butter of React Native accessibility. Every interactive element needs to tell the screen reader what it is (role), what it's called (label), and optionally what it does (hint). The good news: this API is identical on iOS and Android.
accessibilityRole maps to the native trait on each platform. UIAccessibility trait on iOS, and the View's accessibility class name on Android. It tells the screen reader how to announce the element: "button", "link", "header", "image", etc.
Don't use a plain View or TouchableOpacity as a tappable element without communicating its role:
1// bad: a plain View acting as a button2<View onPress={() => open(ability)}>3 <Image source={{ uri: ability.icon }} />4 <Text>{ability.name}</Text>5</View>67// good: uses Pressable with proper role and label8<Pressable9 onPress={() => open(ability)}10 accessibilityRole="button"11 accessibilityLabel={`${ability.name}. Double tap for details.`}12>13 <Image source={{ uri: ability.icon }} />14 <Text>{ability.name}</Text>15</Pressable>
The accessible version uses Pressable (the recommended touchable in modern RN) with an explicit accessibilityRole and accessibilityLabel. VoiceOver announces: "Orb of Deception. Double tap for details, button". TalkBack announces the same. Same result, same API.
One platform difference worth knowing: on iOS, VoiceOver users can disable hints entirely in Settings. On Android, TalkBack always reads them. The implication is that you should never put critical information only in a hint. If the user needs to know it, it belongs in the label.
Images
Meaningful images need descriptive labels. Decorative images should be hidden from the accessibility tree entirely, otherwise the screen reader just says "Image", which is useless.
1// bad: missing alt text, screen reader just says "Image"2<Image source={{ uri: championSplashUrl }} />34// good: descriptive label for both screen readers5<Image6 source={{ uri: championSplashUrl }}7 accessibilityLabel="Ahri, the Nine-Tailed Fox"8/>910// good: decorative image hidden from both VoiceOver and TalkBack11<Image12 source={{ uri: backgroundUrl }}13 accessibilityElementsHidden={true} // iOS VoiceOver14 importantForAccessibility="no-hide-descendants" // Android TalkBack15/>
This is one of the few places where iOS and Android genuinely differ. accessibilityElementsHidden is the iOS prop. importantForAccessibility="no-hide-descendants" is its Android equivalent. Set both and you're covered on both platforms.
Accessibility State
Interactive components often have dynamic state: checked, disabled, expanded, selected. If the screen reader doesn't know about these states, the user is flying blind.
React Native gives us accessibilityState for exactly this, and it works on both platforms:
1// bad: TalkBack says "Mage, button" whether selected or not.2// The user has no way to know what's currently active.3<Pressable4 onPress={() => toggle(role)}5 accessibilityRole="button"6 accessibilityLabel={role}7>8 <Text>{role}</Text>9</Pressable>1011// good: TalkBack says "Mage, selected, button".12// State changes are announced immediately on tap.13<Pressable14 onPress={() => toggle(role)}15 accessibilityRole="button"16 accessibilityLabel={role}17 accessibilityState={{ selected }}18>19 <Text>{role}</Text>20</Pressable>2122// Disabled state on the Apply button23<Pressable24 onPress={applyFilter}25 disabled={!hasSelection}26 accessibilityRole="button"27 accessibilityLabel="Apply filter"28 accessibilityState={{ disabled: !hasSelection }}29>30 <Text>Apply filter</Text>31</Pressable>
Without accessibilityState, TalkBack just says "Mage, button" whether it's selected or not. With it, you get "Mage, selected, button". When the user taps it, both screen readers automatically announce the state change. This is one of those things that's almost invisible when done right, but incredibly frustrating when it's missing.
Adjustable controls
For sliders, steppers, and range inputs, you need accessibilityRole="adjustable" paired with accessibilityValue. Without it, the screen reader lands on three separate controls with no context: "button", "5 / 10", "button". With it, you get: "Champion difficulty filter, 5 of 10, adjustable".
1// bad: without accessibilityValue, the screen reader lands on three separate2// controls with no context: "button", "5 / 10", "button".3// good: with it you get "Champion difficulty filter, 5 of 10, adjustable"4<View5 accessible6 accessibilityRole="adjustable"7 accessibilityLabel="Champion difficulty filter"8 accessibilityValue={{ min: 1, max: 10, now: difficulty, text: `${difficulty} of 10` }}9 accessibilityActions={[10 { name: 'increment', label: 'Increase difficulty' },11 { name: 'decrement', label: 'Decrease difficulty' },12 ]}13 onAccessibilityAction={(event) => {14 if (event.nativeEvent.actionName === 'increment') inc();15 if (event.nativeEvent.actionName === 'decrement') dec();16 }}17>18 <DifficultyPips value={difficulty} />19 {/* Hide the visual +/- buttons from screen readers since the adjustable20 gestures (swipe up/down) replace them */}21 <View importantForAccessibility="no">22 <Controls value={difficulty} onDec={dec} onInc={inc} />23 </View>24</View>
The increment and decrement custom actions are required alongside accessibilityValue. They're how the user actually changes the value via swipe gestures on VoiceOver and TalkBack. Skip them and the control is announce-only.
Element Grouping
Grouping related elements is one of the quickest wins you can hand a screen reader user. If a match card has a champion name, role, result, KDA, CS, and duration, that's six separate swipes. With grouping, it's one.
1// bad: each field is a separate focus stop.2// 6 swipes to read one match card. TalkBack reads each field in isolation.3<View style={styles.card}>4 <Text>{match.champion}</Text>5 <Text>{match.role}</Text>6 <Text>{match.result}</Text>7 <Text>{`${match.k}/${match.d}/${match.a}`}</Text>8 <Text>{`${match.cs} CS`}</Text>9 <Text>{match.duration}</Text>10</View>1112// good: one swipe reads the full card.13// TalkBack: "Ahri, Mid, Victory, 8/3/12 KDA, 187 CS, 34:21"14<View15 accessible16 accessibilityLabel={`${match.champion}, ${match.role}, ${match.result}, ${match.k}/${match.d}/${match.a} KDA, ${match.cs} CS, ${match.duration}`}17 style={styles.card}18>19 <Text>{match.champion}</Text>20 <Text>{match.role}</Text>21 <Text>{match.result}</Text>22 <Text>{`${match.k}/${match.d}/${match.a}`}</Text>23 <Text>{`${match.cs} CS`}</Text>24 <Text>{match.duration}</Text>25</View>
Setting accessible={true} on the parent View tells both iOS and Android to treat the entire subtree as a single focusable element. You then write the combined announcement in accessibilityLabel.
This is especially important for list items and cards. Think match histories, champion profiles, or notification feeds. Without grouping, navigating a list of 20 match cards could mean 120+ swipes. Your screen reader users will thank you.
The nested interactive element trap
accessible={true} makes the parent an atomic element. The screen reader treats the whole subtree as one focusable unit and will not traverse into individual children. On iOS this maps directly to isAccessibilityElement = true, which explicitly prohibits nested accessibility elements. On Android the behaviour is similar.
The practical trap: don't nest interactive elements (buttons, links) inside a grouped container. They become unreachable to screen readers. If the card needs to be tappable and contains a secondary action, use custom actions for the secondary behaviour instead.
1// bad: the "View abilities" button is unreachable inside an accessible container2<View3 accessible4 accessibilityLabel="Ahri, the Nine-Tailed Fox. Tags: Mage, Assassin"5>6 <Text>Ahri</Text>7 <Pressable onPress={() => openAbilities('Ahri')}>8 {/* screen reader can't reach this */}9 <Text>View abilities</Text>10 </Pressable>11</View>1213// good: use a custom action for the secondary behaviour14<View15 accessible16 accessibilityLabel="Ahri, the Nine-Tailed Fox. Tags: Mage, Assassin"17 accessibilityActions={[{ name: 'viewAbilities', label: 'View abilities' }]}18 onAccessibilityAction={(event) => {19 if (event.nativeEvent.actionName === 'viewAbilities') {20 openAbilities('Ahri');21 }22 }}23>24 <Text>Ahri</Text>25 <Text importantForAccessibility="no-hide-descendants">26 View abilities27 </Text>28</View>
Custom Actions
A lot of mobile UI relies on gestures: swipe to delete, long-press for options, drag to reorder. These gestures are invisible to screen readers. Custom accessibility actions let you expose them, and the API works on both platforms.
1// Custom accessibility actions - works on both VoiceOver and TalkBack2<View3 accessible4 accessibilityActions={[5 { name: 'viewAbilities', label: 'View abilities' },6 { name: 'addToTeam', label: 'Add to team' },7 ]}8 onAccessibilityAction={(event) => {9 switch (event.nativeEvent.actionName) {10 case 'viewAbilities':11 openAbilitySheet(champion);12 break;13 case 'addToTeam':14 addToTeam(champion);15 break;16 }17 }}18 accessibilityLabel={`${champion.name}, ${champion.title}. Tags: ${champion.tags.join(', ')}`}19>20 <Image source={{ uri: champion.icon }} />21 <Text>{champion.name}</Text>22 <Text>{champion.title}</Text>23</View>
When a VoiceOver user focuses this element, they can swipe up or down to cycle through the available actions. TalkBack users get the same via the local context menu. Without this, a screen reader user literally cannot perform the action. I use this on the champion cards and any list item that needs more than a single tap interaction.
Live Regions
If a piece of UI updates dynamically (a status message, a loading indicator, a form validation error), you need to tell the screen reader about the change. Live regions are the cleanest way to do this. When the text content of an element with a live region changes, both VoiceOver and TalkBack automatically announce it. No manual API calls needed.
1// bad: status text changes on screen but TalkBack stays silent.2// The user has no idea if the invite worked.3<Text accessibilityLiveRegion="none">4 {statusText}5</Text>67// good: TalkBack announces the status change automatically.8// "Sending invite..." then "Invite sent to Faker."9<Text accessibilityLiveRegion="polite">10 {statusText}11</Text>1213// Values:14// "none" - no announcement (default)15// "polite" - announces when the user is idle (recommended for most cases)16// "assertive" - interrupts immediately (use for errors or urgent alerts only)1718// Android gotcha: keep the element always mounted and change its content.19// Conditionally mounting/unmounting the node is unreliable on TalkBack.20// The announcement fires on a content change, not on a mount.2122// bad: TalkBack often misses this on Android23{status && (24 <View accessibilityLiveRegion="polite">25 <Text>{status}</Text>26 </View>27)}2829// good: element stays in the tree, content changes30<Text accessibilityLiveRegion="polite">31 {status}32</Text>
Use "polite" for most things: status messages, loading states, confirmation text. Use "assertive" only for urgent updates like error alerts, where interrupting the user is warranted.
Imperative Announcements
Sometimes you need to fire an announcement from code in response to a side-effect, not a content change. An invite being sent, an API call completing, a data load finishing. For those cases, use AccessibilityInfo.announceForAccessibility. It works on both platforms.
1import { AccessibilityInfo } from 'react-native';23// Works on both iOS (VoiceOver) and Android (TalkBack)4const sendInvite = async (player: Player) => {5 setStatus('sending');6 try {7 await inviteToGame(player.id);8 setStatus('sent');9 AccessibilityInfo.announceForAccessibility(10 `Invite sent to ${player.name}`11 );12 } catch {13 setStatus('busy');14 AccessibilityInfo.announceForAccessibility(15 `${player.name} is currently in a game`16 );17 }18};1920// Announce loading state transitions21const loadChampions = () => {22 setLoading(true);23 AccessibilityInfo.announceForAccessibility('Loading champions');24 fetchChampions().finally(() => {25 setLoading(false);26 AccessibilityInfo.announceForAccessibility('Champions loaded');27 });28};
I'd recommend using this for:
- Async action results (invites sent, requests failed)
- Data loading completion
- Toast notifications the user might otherwise miss
- Background sync completing
For content that changes in the UI itself (like a status text node updating), prefer accessibilityLiveRegion. It's more declarative and tends to be more reliable, particularly on Android.
Focus Management
After a state transition (opening a modal, dismissing a sheet, navigating to a new screen), the screen reader's focus can end up anywhere. Focus management fixes that, and the API works on both platforms.
1import { useRef } from 'react';2import { AccessibilityInfo, findNodeHandle, View, Text } from 'react-native';34// Store refs for each ability button so we can return focus on close5const abilityRefs = useRef<Record<string, React.RefObject<View>>>({});6const modalTitleRef = useRef<Text>(null);7const lastOpenedKey = useRef<string | null>(null);89const open = (ability: Ability) => {10 lastOpenedKey.current = ability.key;11 setSelected(ability);1213 // Move focus into the sheet after it mounts14 setTimeout(() => {15 const node = findNodeHandle(modalTitleRef.current);16 if (node) AccessibilityInfo.setAccessibilityFocus(node);17 }, 100);18};1920const close = () => {21 const key = lastOpenedKey.current;22 setSelected(null);2324 // Return focus to the button that opened the sheet25 setTimeout(() => {26 if (key) {27 const ref = abilityRefs.current[key];28 const node = findNodeHandle(ref?.current ?? null);29 if (node) AccessibilityInfo.setAccessibilityFocus(node);30 }31 }, 100);32};
AccessibilityInfo.setAccessibilityFocus takes a native node handle (via findNodeHandle) and moves screen reader focus there immediately. I use it when ability detail sheets open (focus moves to the sheet title), when they close (focus returns to the ability button that triggered it), and after navigating to a new screen.
Modal containment
Moving focus into a modal isn't enough on its own. Background content can still be reached by swiping, which is disorienting. You need to contain focus to the modal itself.
On iOS, accessibilityViewIsModal={true} on the modal view tells VoiceOver to ignore everything outside it. On Android, importantForAccessibility="no-hide-descendants" on the background content achieves the same for TalkBack. Both are needed for proper cross-platform containment.
1// When the ability detail sheet opens:2// 1. accessibilityViewIsModal traps focus inside the sheet3// 2. setAccessibilityFocus moves TalkBack/VoiceOver to the sheet title4// 3. On close, focus returns to the ability button that opened it56// The sheet (iOS + Android)7<View style={styles.sheet} accessibilityViewIsModal>8 <Text9 ref={modalTitleRef}10 accessibilityRole="header"11 style={styles.sheetTitle}12 >13 {selected.name}14 </Text>15 <Text>{selected.description}</Text>1617 // good: group table-like rows so TalkBack reads both in one swipe.18 // Without this, "Cooldown", "7s", "Cost", "65 Mana" are 4 focus stops.19 <View20 style={styles.sheetMeta}21 accessible22 accessibilityLabel={`Cooldown, ${selected.cooldown}. Cost, ${selected.cost}`}23 >24 <View style={styles.metaItem}>25 <Text style={styles.metaLabel}>Cooldown</Text>26 <Text style={styles.metaValue}>{selected.cooldown}</Text>27 </View>28 <View style={styles.metaItem}>29 <Text style={styles.metaLabel}>Cost</Text>30 <Text style={styles.metaValue}>{selected.cost}</Text>31 </View>32 </View>3334 <Pressable35 onPress={close}36 accessibilityRole="button"37 accessibilityLabel="Close ability details"38 >39 <Text>Close</Text>40 </Pressable>41</View>4243// The background content (Android needs this explicitly)44<View importantForAccessibility={selected ? 'no-hide-descendants' : 'auto'}>45 {/* ability buttons behind the sheet */}46</View>
Focus management (moving focus to the modal title on open, back to the trigger on close) and containment are both required. One without the other leaves gaps.
Adaptive UI
Sometimes the right call is to detect whether a screen reader is active and adapt the UI accordingly. Complex gesture-based interactions (swipe carousels, drag-and-drop) can be swapped for simpler button-based alternatives. AccessibilityInfo.isScreenReaderEnabled returns true for both VoiceOver and TalkBack, so you don't need separate logic per platform.
1import { useEffect, useState } from 'react';2import { AccessibilityInfo } from 'react-native';34// Works for both VoiceOver (iOS) and TalkBack (Android)5export const useScreenReader = () => {6 const [isActive, setIsActive] = useState(false);78 useEffect(() => {9 AccessibilityInfo.isScreenReaderEnabled().then(setIsActive);10 const sub = AccessibilityInfo.addEventListener(11 'screenReaderChanged',12 setIsActive13 );14 return () => sub.remove();15 }, []);1617 return isActive;18};1920// Usage - swap gesture-based interactions for button alternatives21const ChampionCard = ({ champion }) => {22 const isScreenReaderActive = useScreenReader();2324 return isScreenReaderActive ? (25 <Pressable26 onPress={() => openDetails(champion)}27 accessibilityRole="button"28 accessibilityLabel={`${champion.name}, ${champion.title}. Double tap for details.`}29 >30 <Text>{champion.name}</Text>31 </Pressable>32 ) : (33 <SwipeableRow onSwipe={() => openDetails(champion)}>34 <Text>{champion.name}</Text>35 </SwipeableRow>36 );37};
Don't go overboard with this though. The goal is equivalent functionality, not a completely different app. Use it for interactions that genuinely can't be made accessible through roles, labels, and custom actions alone.
Testing
Writing automated tests that query by accessibility role and label is one of the best ways to catch regressions. If your test can find a button by its accessible name, a screen reader user can too.
1import { render, screen, fireEvent } from '@testing-library/react-native';23test('ability button has accessible role and label', () => {4 render(<AbilityButton ability={orbOfDeception} />);56 const button = screen.getByRole('button', {7 name: 'Orb of Deception. Double tap for details.',8 });9 expect(button).toBeTruthy();10});1112test('role chip announces selected state', () => {13 render(<RoleChip role="Mage" selected={true} withState />);1415 const chip = screen.getByRole('button', { name: 'Mage' });16 expect(chip.props.accessibilityState).toEqual({ selected: true });17});
The @testing-library/react-native API is great for this. Querying by getByRole and getByLabelText forces you to write accessible components. The tests literally won't pass unless the accessibility props are correct.
Beyond automated tests, manual testing on real devices is worth doing on both platforms:
- iOS VoiceOver. Settings > Accessibility > VoiceOver. Swipe to navigate, double-tap to activate. The triple-click shortcut is worth setting up so you can toggle it quickly during development.
- Android TalkBack. Settings > Accessibility > TalkBack. Same gesture model: swipe to navigate, double-tap to activate. On an emulator, skip the Settings detour entirely and toggle it via adb from the terminal. Worth setting up as shell aliases:
- Xcode Accessibility Inspector. Lets you inspect the accessibility tree without navigating with VoiceOver. Great for quickly checking roles and labels.
- Android Accessibility Scanner. A Google app that scans your UI for common issues: missing labels, small touch targets, low contrast. Available on the Play Store.
- QA checklists. Add a screen reader pass step to your PR review process. Any PR touching interactive UI should get a basic accessibility check before it merges.
1# enable TalkBack on emulator2adb shell settings put secure enabled_accessibility_services \3 com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService \4 && adb shell settings put secure accessibility_enabled 156# disable TalkBack on emulator7adb shell settings put secure enabled_accessibility_services "" \8 && adb shell settings put secure accessibility_enabled 0910# handy aliases to drop in your .bashrc / .zshrc11alias talkback-on="adb shell settings put secure enabled_accessibility_services com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService && adb shell settings put secure accessibility_enabled 1"12alias talkback-off='adb shell settings put secure enabled_accessibility_services "" && adb shell settings put secure accessibility_enabled 0'
ADB accessibility settings
Screen reader testing only covers one dimension. Your app also needs to hold up under the accessibility preferences real users actually set: large font sizes, colour correction for colour vision deficiency, high contrast text, colour inversion. On Android, you can toggle all of these from the terminal without touching the Settings UI:
1# font scale (default is 1.0)2adb shell settings put system font_scale 1.0 # default3adb shell settings put system font_scale 1.3 # largest available in UI4adb shell settings put system font_scale 2.0 # beyond the UI maximum56# colour correction / colour vision deficiency simulation7adb shell settings put secure accessibility_display_daltonizer_enabled 18adb shell settings put secure accessibility_display_daltonizer 11 # deuteranomaly (red-green)9adb shell settings put secure accessibility_display_daltonizer 12 # protanomaly (red-green)10adb shell settings put secure accessibility_display_daltonizer 13 # tritanomaly (blue-yellow)11adb shell settings put secure accessibility_display_daltonizer 0 # monochromatic1213# disable colour correction14adb shell settings put secure accessibility_display_daltonizer_enabled 01516# colour inversion17adb shell settings put secure accessibility_display_inversion_enabled 11819# high contrast text20adb shell settings put secure high_text_contrast_enabled 12122# dump the accessibility node tree (what TalkBack actually sees)23adb shell dumpsys accessibility
The font scale commands are especially useful alongside the Font Scaling section earlier in this article. font_scale 2.0 goes beyond what the Android settings UI lets you select, so it's a good stress test.
The dumpsys accessibility command at the bottom dumps the full accessibility node tree as Android sees it: content descriptions, roles, states, bounds, available actions. If a TalkBack announcement sounds wrong and you can't figure out why, this shows you exactly what the screen reader is working with. You can pipe it to a file and diff between changes.
Credit to Mark Han's ADB accessibility gist for a more complete reference of available settings.
Inspectors vs real devices
Inspectors are good for checking structure, but they can't tell you what the experience actually feels like. You need both.
What inspectors can't tell you:
- Whether gesture navigation feels natural
- Whether announcement timing is correct
- Whether focus transitions are disorienting
- Whether the cognitive load of a screen is reasonable
Use the Xcode Accessibility Inspector and Android Accessibility Scanner to catch missing labels and role errors quickly. Use real devices with VoiceOver and TalkBack to test the actual experience. Both matter.
Touch Targets
Accessibility isn't only about screen readers. Motor accessibility matters too, and small touch targets are one of the most common issues in React Native apps.
The platform minimums are 44x44pt on iOS (from the Human Interface Guidelines) and 48x48dp on Android (Material Design). A 24px icon sitting in a tight grid almost certainly falls short.
hitSlop is the fix. It expands the tappable area without affecting the visual layout:
1// Visually 24px icon, but tappable 44px area2<Pressable3 onPress={close}4 accessibilityRole="button"5 accessibilityLabel="Close ability details"6 hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}7>8 <CloseIcon size={24} />9</Pressable>
Worth adding to your component review checklist: anything with an icon button under 44pt needs a hitSlop. Close buttons, share icons, overflow menus. They're almost always too small.
Font Scaling
Users with low vision increase their system font size, sometimes by a lot. React Native's Text component respects this by default: allowFontScaling is true on every Text element. Don't disable it without a good reason.
1// bad: disables scaling for visual convenience - breaks it for users who need it2<Text allowFontScaling={false}>Orb of Deception</Text>34// bad: fixed height clips ability description at large font sizes5<View style={{ height: 48 }}>6 <Text>{ability.description}</Text>7</View>89// good: minimum height lets the container grow with the text10<View style={{ minHeight: 48 }}>11 <Text>{ability.description}</Text>12</View>
The usual culprit for layout breakage at large font sizes is a fixed height on a container. Swap it for minHeight and the container expands with the text instead of clipping it.
Final Thoughts
Accessibility on React Native isn't some separate discipline. It's just good engineering. It's less work than you'd expect. Most of the APIs in this article are identical on iOS and Android. You write it once and it works for both VoiceOver and TalkBack.
Adding accessibility props to your components makes codebases better in ways that aren't immediately obvious. Components with proper roles and labels are easier to test. Grouped elements lead to cleaner component APIs. Focus management forces you to think about state transitions more carefully.
React Native Accessibility Checklist
- Interactive elements have accessibilityRole, and labels describe the action (not the visual)
- Dynamic state uses accessibilityState, not the label. Adjustable controls pair accessibilityRole="adjustable" with accessibilityValue
- Cards and list items are grouped with accessible={true}, with no nested interactive children (use custom actions instead)
- Live regions on dynamic content, focus moved programmatically after major transitions
- Modals isolate background content (accessibilityViewIsModal on iOS, importantForAccessibility on Android)
- Touch targets meet 44x44pt (iOS) / 48x48dp (Android), and allowFontScaling isn't disabled without a reason
- Manual VoiceOver and TalkBack pass at the largest system font size before merge
If you're starting from zero, focus on the fundamentals: roles, labels, states, and grouping. Those four will get you 80% of the way there on both platforms. Add live regions and imperative announcements for dynamic content and you've covered most of what you'll run into.
If you start adding accessibilityRole and accessibilityLabel to your components after reading this, that's a win.
If you haven't already, check out my other accessibility articles:
Lint it
One tool worth the five minutes to set up: eslint-plugin-react-native-a11y . It catches the mistakes you'll make while writing this stuff: a Pressable without an accessibilityRole, an icon-only button with no label, an accessibilityRole="button" with no onPress. Cheapest guardrail you can add and it saves the back-and-forth in code review.
Claude Code Skill
I packaged everything in this article into a Claude Code skill. Drop the SKILL.md into .claude/skills/react-native-accessibility/ in any repo and Claude will load it when you're working on React Native UI or component tests.
Further Reading
Some extra reading if you want to dig in:
Was this useful?
Thanks for reading! Time for one more?
const intervalRef = useRef(null)const resetGame = useCallback(() => {clearInterval(intervalRef.current)}, [])
πΉοΈ Building 'Simple' React Games
21 Apr 2026 β’ π 19 min readWhat I learned refactoring two browser games: grouping state, useReducer, stale closures, timer cleanup, render-time randomisation bugs, and replacing three intervals with one rAF loop.
const { userId } = useLocalSearchParams()useEffect(() => {getUserDetails(userId).then(setUser)}, [userId])
π¦Έ Expo & Expo Router
21 Jan 2024 β’ π 30 min read β’ Updated 16 Mar 2026Expo, EAS & Expo Router: one codebase covering web, Android, and iOS, with managed workflows, OTA updates, and file-based routing.