TL;DR
- The DOJ's ADA Title II rule (WCAG 2.1 AA for state/local gov web + mobile apps) got pushed back. The big-entity deadline is now April 26, 2027, smaller entities April 26, 2028. The EU's Accessibility Act has been live since June 2025 and points at WCAG 2.2 AA. Translation: the clock is still running.
- React Native maps almost 1:1 onto iOS
UIAccessibilityand AndroidAccessibilityNodeInfo. The API is small. The hard part is knowing which prop satisfies which criterion. - WCAG 2.2 added four criteria that hit native apps directly: 2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements, 2.5.8 Target Size, 3.3.8 Accessible Authentication.
- Five bugs cause most failures: unlabeled
Pressables, modals that don't trap focus, silent form errors, switches with no state, and text on gradients. - Test in four layers: lint in CI → screen reader walkthrough → Xcode Inspector / Android Scanner → Appium + axe for regression.
Let's get into it.
Why you should care (even though the deadline moved)
Quick recap of where things stand as of late 2026:
- ADA Title II (US): The DOJ's 2024 rule was the first US regulation to name mobile apps explicitly and tie them to a WCAG version (2.1 AA). In April 2026, four days before it was supposed to bite, DOJ extended the dates by a year. Entities serving 50,000+ people now have until April 26, 2027. Everyone else gets until April 26, 2028. The underlying Title II nondiscrimination obligations didn't go anywhere.
- HHS Section 504: If your app is built for anyone receiving HHS funding (hospitals, clinics, health programs), that rule kept its May 2026 date.
- European Accessibility Act: In force since June 2025. Covers private-sector stuff like banking, e-commerce, and transport apps, and references EN 301 549, which lines up with WCAG 2.2 AA.
- Procurement: B2G contracts routinely list WCAG 2.1 AA as a pass/fail checkbox, so even if a regulation doesn't technically cover you, your customer's contract might.
- Private lawsuits: Title III web and app suits keep coming every year. "We'll fix it later" is not a strategy your legal team will enjoy.
So the practical move hasn't changed: run an accessibility audit on every store build, and be able to show the report when someone asks.
The whole toolbox in one component
WCAG has four principles (Perceivable, Operable, Understandable, Robust) and three conformance levels (A, AA, AAA). For Level AA on native mobile, you're working through the A + AA criteria that apply to apps. A handful of web-only ones (skip links, document heading outline, parsing) don't translate.
Everything else lands on this prop surface:
<Pressable
accessible
accessibilityLabel="Save changes"
accessibilityHint="Saves your draft"
accessibilityRole="button"
accessibilityState={{ disabled: false, busy: isSaving }}
accessibilityValue={{ min: 0, max: 100, now: 42 }}
importantForAccessibility="yes"
accessibilityLiveRegion="polite"
>
<Text>Save</Text>
</Pressable>
That's basically it. (On RN 0.71+ you can also use the ARIA-style aliases like role, aria-label, and aria-busy. Same thing under the hood.)
Now the mapping.
Perceivable + Operable
1.1.1 Non-text Content (A)
Anything meaningful that isn't text needs a label. Anything decorative should disappear from the accessibility tree.
// Meaningful icon button
<Pressable accessibilityRole="button" accessibilityLabel="Delete message" onPress={onDelete}>
<TrashIcon />
</Pressable>
// Decorative image: hide it on both platforms
<Image
source={confetti}
accessible={false}
accessibilityElementsHidden // iOS
importantForAccessibility="no-hide-descendants" // Android
/>
1.3.1 Info and Relationships (A)
Visual structure must be exposed in code. A styled View with onPress is just a blob to a screen reader until you give it a role.
<Text accessibilityRole="header" style={styles.h1}>Account settings</Text>
<Pressable accessibilityRole="link" onPress={openTerms}>
<Text>Terms of service</Text>
</Pressable>
1.4.3 Contrast (Minimum) (AA)
4.5:1 for body text, 3:1 for large text (18pt+, or 14pt+ bold). Don't eyeball it. Put it in a test so your design tokens can't regress:
// contrast.js
const toRgb = (hex) => {
const n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const luminance = (hex) => {
const [r, g, b] = toRgb(hex).map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
export const contrastRatio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// tokens.test.js
import { contrastRatio } from './contrast';
import { colors } from './theme';
const pairs = [
['textPrimary', 'background'],
['textSecondary', 'background'],
['onPrimary', 'primary'],
];
test.each(pairs)('%s on %s meets AA (4.5:1)', (fg, bg) => {
expect(contrastRatio(colors[fg], colors[bg])).toBeGreaterThanOrEqual(4.5);
});
Classic RN failure: text over a gradient hero. It passes against the top color and fails against the bottom one. Test against the worst stop, or put a semi-opaque scrim behind the text.
1.4.4 Resize Text + 1.4.10 Reflow (AA)
Text must scale to 200% without clipping. allowFontScaling is on by default, so Dynamic Type and Android font scale already work. What breaks is your layout:
// ❌ clips at large font sizes
<View style={{ height: 48 }}><Text>{title}</Text></View>
// ✅ grows with the text
<View style={{ minHeight: 48, paddingVertical: 12 }}><Text>{title}</Text></View>
If you must cap scaling for a tight UI element, use maxFontSizeMultiplier={1.5} instead of disabling scaling entirely.
2.1.1 Keyboard (A)
On mobile this means hardware keyboards: iPads, foldables, Android with Bluetooth. Every custom gesture needs a non-gesture path, which ties straight into 2.5.7 below.
2.4.3 Focus Order (A) + 2.4.11 Focus Not Obscured (AA, new)
Focus follows render/view-hierarchy order, which usually matches the visual order. It stops matching once you start using position: 'absolute' and zIndex. The usual offender is a custom bottom sheet built as an absolutely positioned View:
<>
<View
accessibilityElementsHidden={sheetOpen} // iOS
importantForAccessibility={sheetOpen ? 'no-hide-descendants' : 'auto'} // Android
>
<MainScreen />
</View>
{sheetOpen && (
<View accessibilityViewIsModal style={styles.sheet}>
<SheetContent />
</View>
)}
</>
For 2.4.11, don't let the keyboard cover the focused input:
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView keyboardShouldPersistTaps="handled">{/* form */}</ScrollView>
</KeyboardAvoidingView>
2.5.8 Target Size (Minimum) (AA, new)
The floor is 24×24 CSS px. The platform guidelines want 44×44 (iOS) and 48×48 (Android), so aim for those. Icon-only buttons are the repeat offender. hitSlop fixes them without touching the visual design:
<Pressable
accessibilityRole="button"
accessibilityLabel="Close"
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
onPress={onClose}
>
<CloseIcon size={20} />
</Pressable>
2.5.7 Dragging Movements (AA, new)
Reorderable lists need a non-drag alternative. Drag libraries don't hand you this. For screen reader users, custom accessibility actions are the cleanest route:
<View
accessible
accessibilityLabel={`${item.title}, position ${index + 1} of ${items.length}`}
accessibilityActions={[
{ name: 'moveUp', label: 'Move up' },
{ name: 'moveDown', label: 'Move down' },
]}
onAccessibilityAction={({ nativeEvent }) => {
if (nativeEvent.actionName === 'moveUp' && index > 0) move(index, index - 1);
if (nativeEvent.actionName === 'moveDown' && index < items.length - 1) move(index, index + 1);
}}
>
<DraggableRow item={item} />
</View>
2.5.7 applies to everyone, not just screen reader users, so also ship a visible long-press menu or up/down buttons.
Understandable + Robust
This half of the audit is smaller, but the failures here block people from finishing a signup or a checkout. Those are the ones that end up in demand letters.
3.3.1 Error Identification (A) + 3.3.3 Error Suggestion (AA)
A red border is invisible to VoiceOver. Announce the error and attach it to the field:
const onSubmit = () => {
if (!isValidEmail(email)) {
const msg = 'Email is invalid. Use the format name@example.com';
setEmailError(msg);
AccessibilityInfo.announceForAccessibility(msg);
return;
}
submit();
};
<TextInput
value={email}
onChangeText={setEmail}
accessibilityLabel={emailError ? `Email, error: ${emailError}` : 'Email'}
/>
{emailError ? <Text style={styles.error}>{emailError}</Text> : null}
3.3.2 Labels or Instructions (A)
Placeholder-only inputs fail, because the placeholder vanishes on focus. Use a persistent visible label:
<Text nativeID="emailLabel">Email</Text>
<TextInput
accessibilityLabel="Email" // works on both platforms
accessibilityLabelledBy="emailLabel" // Android only
placeholder="name@example.com"
/>
3.3.8 Accessible Authentication (Minimum) (AA, new)
Let password managers do their job, and never block paste:
<TextInput
secureTextEntry
textContentType="password" // iOS autofill
autoComplete="password" // Android autofill
accessibilityLabel="Password"
/>
Disabling paste "for security" fails 3.3.8 and pushes users toward weaker passwords. Biometric login via expo-local-authentication is a solid extra path.
4.1.2 Name, Role, Value (A)
Every custom control needs all three. Here's a custom toggle:
<Pressable
onPress={() => setEnabled((v) => !v)}
accessibilityRole="switch"
accessibilityLabel="Dark mode"
accessibilityState={{ checked: enabled }}
>
<ToggleVisual on={enabled} />
</Pressable>
VoiceOver reads: "Dark mode, switch, on." Drop any one of the three props and you fail 4.1.2.
4.1.3 Status Messages (AA)
Toasts, "Saved!", and loading states should be announced without stealing focus. Android uses live regions. iOS needs an explicit announcement:
function useStatusAnnouncement(message) {
useEffect(() => {
if (message && Platform.OS === 'ios') {
AccessibilityInfo.announceForAccessibility(message);
}
}, [message]);
}
function SaveStatus({ message }) {
useStatusAnnouncement(message);
return <Text accessibilityLiveRegion="polite">{message}</Text>;
}
The 5 bugs you'll find in almost every audit
| # | Bug | Fix |
|---|---|---|
| 1 | Icon-only Pressable, no label |
accessibilityLabel + accessibilityRole="button"
|
| 2 | Custom modal/sheet doesn't trap focus |
accessibilityViewIsModal (iOS) + hide background with importantForAccessibility="no-hide-descendants" (Android) |
| 3 | Form errors are visual-only |
AccessibilityInfo.announceForAccessibility() on failure |
| 4 | Custom switch/checkbox reads as "button" | Correct role + accessibilityState={{ checked }}
|
| 5 | Text on a gradient | Test against the worst stop, or add a scrim |
The first four are pure "forgot the prop" bugs, and they're the easiest to prevent at the source. Some teams do it with a strict component library that makes the label a required prop. Others use codegen that writes them for you. RapidNative, for example, treats accessibilityLabel, accessibilityRole, and target-safe hitSlop as required on every generated interactive element. Contrast (#5) depends on your palette, so it's the one you'll still want the token test from earlier for.
If you're doing it by hand, a typed wrapper gets you most of the way:
type A11yPressableProps = PressableProps & {
accessibilityLabel: string; // required, not optional
accessibilityRole: AccessibilityRole;
};
export function A11yPressable(props: A11yPressableProps) {
return <Pressable hitSlop={8} {...props} />;
}
Ban raw Pressable in feature code with a lint rule, and TypeScript enforces the rest.
Testing: the 4-layer workflow
Layer 1: Lint in CI.
npm i -D eslint-plugin-react-native-a11y
// .eslintrc.js
module.exports = {
plugins: ['react-native-a11y'],
extends: ['plugin:react-native-a11y/all'],
};
This catches missing labels, invalid roles, and unlabeled inputs. It can't see contrast or focus order. No linter can.
Layer 2: Screen reader walkthrough. Turn on VoiceOver (iOS) or TalkBack (Android) and go through each primary flow without looking at the screen. It's about 20 minutes per flow, and it finds more real problems than any other step.
Layer 3: Device audit tools. Xcode → Open Developer Tool → Accessibility Inspector runs an audit against the simulator: contrast, missing labels, hit targets. On Android, use Accessibility Scanner.
Layer 4: Automated regression. axe DevTools Mobile plugs into Appium and runs WCAG rulesets against built apps. It's paid, but it's how you prove compliance at CI speed on a big app.
Cadence that works:
every commit → Layer 1
every PR touching UI → Layer 2
every store build → Layers 3 + 4
For a weekly release train, that's about a 2-hour accessibility pass. Retrofitting a 40-screen app that never had props usually takes weeks.
Where to start today
Don't start with a spreadsheet of 50+ criteria. Start with Layer 2. Turn on VoiceOver or TalkBack, open your app, and try to complete your main flow. Whatever you trip over first is your backlog's #1.
Reference specs if you want to go deeper: WCAG 2.2 and what's new in 2.2.
What's the worst accessibility bug you've found in your own React Native app? Or which WCAG criterion are you still stuck on? Drop it in the comments. I'm reading every one.
Top comments (0)