TL;DR
- The legal pressure is real. The DOJ's ADA Title II rule names mobile apps and WCAG 2.1 AA explicitly (deadlines now April 2027 / April 2028). The EU's EAA has been live since June 2025 and points to WCAG 2.2 AA via EN 301 549.
-
React Native maps almost 1:1 to native a11y APIs.
accessibilityLabelbecomes the iOS accessibility label and the Android content description. You already have the tools. - WCAG 2.2 adds four mobile-relevant criteria: Focus Not Obscured, Dragging Movements, Target Size (24×24 min), and Accessible Authentication.
-
Five bugs cause most failures: unlabeled
Pressables, modals that don't trap focus, visual-only form errors, custom switches with no state, and low-contrast text on gradients. - Test in four layers: ESLint on every commit, a VoiceOver/TalkBack walkthrough on every UI PR, and Accessibility Inspector/Scanner plus axe before every store submission.
Why this suddenly matters
Accessibility used to be the "we'll get to it" ticket. Three things changed that.
1. The DOJ Title II rule. Published in April 2024, it's the first U.S. federal regulation to name mobile apps alongside websites and tie them to a specific standard: WCAG 2.1 AA. In April 2026 the DOJ pushed the deadlines back a year. Public entities with 50,000+ population now have until April 26, 2027, and smaller entities until April 26, 2028. The requirements themselves didn't change. State procurement contracts already cite WCAG 2.1 AA as a pass/fail checkbox, so if you build B2G apps you're in scope today.
2. The European Accessibility Act. In force since June 2025, it covers private-sector products like banking, e-commerce and transit apps. It references EN 301 549, which is harmonized with WCAG 2.2 AA. If you ship to EU users above the small-business threshold, it applies to you.
3. Litigation. Mobile app accessibility lawsuits more than doubled between 2022 and 2025. Courts keep treating apps as places of public accommodation under ADA Title III. Insurers have started asking about accessibility testing before writing cyber-liability policies.
So: you need an a11y audit in your release checklist, run on every store build, with a report you can point to.
The entire React Native a11y toolbox
WCAG 2.2 has 86 success criteria across three conformance levels (A, AA, AAA). For Level AA you need to pass the 55 A and AA criteria, minus a few web-only ones like skip links. Everything else maps to this prop surface:
<Pressable
accessible={true}
accessibilityLabel="Save changes"
accessibilityHint="Saves your draft"
accessibilityRole="button"
accessibilityState={{ disabled: false, busy: isSaving }}
accessibilityValue={{ min: 0, max: 100, now: 42 }}
importantForAccessibility="yes" // Android
accessibilityLiveRegion="polite" // Android
>
<Text>Save</Text>
</Pressable>
That's it. The hard part isn't the API. It's knowing which prop satisfies which criterion, and making those props the default so nobody ships a screen without them.
The four criteria new in WCAG 2.2 that hit native UIs:
| Criterion | Level | What it means in an app |
|---|---|---|
| 2.4.11 Focus Not Obscured (Min) | AA | Focused element can't be fully hidden by sticky headers, bottom sheets or the keyboard |
| 2.5.7 Dragging Movements | AA | Every drag gesture needs a non-drag alternative |
| 2.5.8 Target Size (Min) | AA | Targets at least 24×24 (aim for 44×44 iOS / 48×48 Android) |
| 3.3.8 Accessible Authentication (Min) | AA | No cognitive tests to log in. Allow paste and password managers |
Perceivable + Operable: criterion → code
1.1.1 Non-text Content (A)
Label meaningful images and hide decorative ones.
// Meaningful
<Image source={avatar} accessibilityLabel="Profile photo of Priya" />
// Decorative: hide on both platforms
<Image
source={divider}
accessibilityElementsHidden={true} // iOS
importantForAccessibility="no-hide-descendants" // Android
/>
1.3.1 Info and Relationships (A)
A styled View with an onPress is invisible to screen readers as a control. Declare the role.
<Text accessibilityRole="header" style={styles.h1}>Settings</Text>
<Pressable accessibilityRole="link" onPress={openTerms}>
<Text>Terms of service</Text>
</Pressable>
1.4.3 Contrast (Minimum) (AA)
Body text needs 4.5:1. Large text (18pt+, or 14pt+ bold) needs 3:1. The classic React Native failure is text on a gradient hero that passes at the top and fails at the bottom. Use semantic tokens so a palette change doesn't mean re-auditing every screen:
// theme.js: pairs pre-checked for AA contrast
export const colors = {
textPrimary: '#1A1A1A',
surface: '#FFFFFF',
textOnBrand: '#FFFFFF',
brand: '#0B5FFF',
};
1.4.4 Resize Text + 1.4.10 Reflow (AA)
Text must scale to 200% without clipping. allowFontScaling is on by default, so leave it alone. Fixed heights are what break:
// ❌ clips at large Dynamic Type sizes
<View style={{ height: 48 }}><Text>{label}</Text></View>
// ✅ grows with the text
<View style={{ minHeight: 48 }}><Text>{label}</Text></View>
2.1.1 Keyboard (A)
Hardware keyboards are a real case on iPads, foldables and Android devices with Bluetooth keyboards. TextInput's onKeyPress covers most of it. Any custom gesture needs an alternative, which also covers 2.5.7 below.
2.4.3 Focus Order (A) + 2.4.11 Focus Not Obscured (AA)
Focus order follows the view hierarchy, which usually matches the visual order. It breaks when you use absolute positioning or zIndex. Modals need to trap focus:
<View
importantForAccessibility={modalOpen ? 'no-hide-descendants' : 'auto'}
accessibilityElementsHidden={modalOpen}
>
{/* background screen */}
</View>
<Modal visible={modalOpen}>
<View accessibilityViewIsModal={true}>{/* modal content */}</View>
</Modal>
For 2.4.11, keep focused inputs above the keyboard:
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView keyboardShouldPersistTaps="handled">{/* form */}</ScrollView>
</KeyboardAvoidingView>
2.5.8 Target Size (AA)
The usual offender is a 16pt icon button with no padding. hitSlop enlarges the touch area without changing the visuals:
<Pressable
onPress={close}
hitSlop={{ top: 14, bottom: 14, left: 14, right: 14 }}
accessibilityRole="button"
accessibilityLabel="Close"
>
<Icon name="x" size={16} />
</Pressable>
2.5.7 Dragging Movements (AA)
react-native-draggable-flatlist won't give you an alternative for free. The cleanest RN-native fix is custom accessibility actions, which screen reader users reach through the VoiceOver rotor or the TalkBack actions menu:
<View
accessible
accessibilityLabel={`${item.title}, position ${index + 1}`}
accessibilityActions={[
{ name: 'moveUp', label: 'Move up' },
{ name: 'moveDown', label: 'Move down' },
]}
onAccessibilityAction={({ nativeEvent }) => {
if (nativeEvent.actionName === 'moveUp') move(index, index - 1);
if (nativeEvent.actionName === 'moveDown') move(index, index + 1);
}}
>
{/* draggable row */}
</View>
Add a visible long-press menu with the same options for sighted users who can't drag.
Understandable + Robust: forms, roles, state
These are the failures that get apps sued, because they stop people from completing a transaction.
3.3.1 Error Identification + 3.3.3 Error Suggestion
A red border means nothing to a screen reader. Announce the error:
import { AccessibilityInfo } from 'react-native';
function onSubmit() {
if (!email) {
setError('Email is required');
AccessibilityInfo.announceForAccessibility('Email is required');
return;
}
}
<TextInput
accessibilityLabel={error ? `Email, error: ${error}` : 'Email'}
value={email}
onChangeText={setEmail}
/>
3.3.2 Labels or Instructions (A)
Placeholder-only inputs fail because the placeholder disappears when you type. Use a persistent label. On Android you can link the two with accessibilityLabelledBy + nativeID:
<Text nativeID="emailLabel">Email</Text>
<TextInput
accessibilityLabel="Email" // iOS
accessibilityLabelledBy="emailLabel" // Android
/>
3.3.8 Accessible Authentication (AA, new in 2.2)
Let password managers do their job, and never block paste:
<TextInput
secureTextEntry
textContentType="password" // iOS
autoComplete="password" // Android + iOS
contextMenuHidden={false} // keep paste available
/>
4.1.2 Name, Role, Value (A)
Custom controls need all three. Miss one and the control fails:
<Pressable
onPress={() => setEnabled(!enabled)}
accessibilityRole="switch"
accessibilityLabel="Dark mode"
accessibilityState={{ checked: enabled }}
>
{/* visual toggle */}
</Pressable>
VoiceOver reads it as "Dark mode, switch, on." 👌
4.1.3 Status Messages (AA)
Toasts and "Saved" confirmations should be announced without moving focus:
<Text accessibilityLiveRegion="polite">{statusMessage}</Text> // Android
useEffect(() => {
if (statusMessage) AccessibilityInfo.announceForAccessibility(statusMessage); // iOS + Android
}, [statusMessage]);
The 4-layer testing workflow
You can't claim compliance you can't demonstrate.
Layer 1: Lint on every commit.
npm i -D eslint-plugin-react-native-a11y
{
"plugins": ["react-native-a11y"],
"extends": ["plugin:react-native-a11y/all"]
}
This catches missing labels, invalid roles and unlabeled inputs. It won't catch contrast or focus order, and no linter can.
Layer 2: Screen reader walkthrough on every UI PR. Turn on VoiceOver (Settings → Accessibility → VoiceOver) or TalkBack, close your eyes, and complete each primary flow. It takes about 20 minutes per flow and catches more real bugs than anything else on this list.
Layer 3: Platform auditors before release. On iOS, use Xcode → Open Developer Tool → Accessibility Inspector. On Android, use Google's Accessibility Scanner. Both flag contrast, missing labels and small targets.
Layer 4: Automated regression. axe DevTools Mobile plugs into Appium and runs WCAG 2.2 rulesets against built apps. It's pricey, but it's how you prove compliance at CI speed on a big app.
If you ship weekly, that's roughly a 2-hour a11y pass per release.
The 5 bugs I see in almost every RN audit
| # | Bug | Fix |
|---|---|---|
| 1 | Icon-only Pressable with no label |
accessibilityLabel + accessibilityRole="button"
|
| 2 | Modal doesn't trap focus |
accessibilityViewIsModal (iOS) + hide background (Android) |
| 3 | Form errors are visual only | AccessibilityInfo.announceForAccessibility() |
| 4 | Custom switch reads as "button" |
accessibilityRole="switch" + accessibilityState={{ checked }}
|
| 5 | Low contrast on gradients | Semi-opaque overlay behind text, or move the text |
Bugs 1–4 are all "forgot a prop" bugs, which makes them a defaults problem more than a skills problem. Your component library and codegen should require these props on interactive elements so the accessible path is the easy path. Some generators already work this way. RapidNative, for example, outputs screens with accessibilityRole, accessibilityLabel, accessibilityState and hitSlop already filled in. Contrast (bug 5) is harder to automate because it depends on your palette, but AA-checked semantic tokens get you most of the way.
Build it in, don't bolt it on
Retrofitting a 40-screen app that shipped without a11y props takes 2–4 weeks of focused work. Building those props in from day one costs close to nothing. The cheapest fix is upstream:
- Make
accessibilityLabelandaccessibilityRolerequired in your design-system components (TypeScript helps here). - Turn on
eslint-plugin-react-native-a11ywith errors, not warnings. - Ship AA-safe color tokens and
hitSlopdefaults in your baseButtonandIconButton.
Then do the one thing that beats every tool: spend 20 minutes with VoiceOver on your current build.
What's the worst a11y bug you've found in your own app? Drop it in the comments 👇
Top comments (0)