07 · Accessibility in React Native¶
A meaningful share of your users rely on a screen reader, larger text, higher contrast, reduced motion, switch control or voice control. On mobile, accessibility is built into the OS: VoiceOver on iOS and TalkBack on Android read the screen aloud and let users navigate by swiping between elements. React Native maps its accessibility props to those native systems, so doing it right is mostly about giving each element the right information.
General principles of accessible design are covered in the HTML & CSS Mastery Path and the React course; this lesson is about the React Native specifics.
How a screen reader sees your app¶
A screen reader moves focus element by element. For each focused element it announces, roughly: label, role, state, then optional hint. For example: "Drink water, checkbox, checked. Double tap to toggle." Your job is to make sure every interactive element has all four, and that decorative or redundant elements don't add noise.
The essential props¶
<Pressable
accessible // treat as one focusable element (default for Pressable)
accessibilityRole="checkbox" // what it is
accessibilityLabel="Drink water" // what it's called
accessibilityState={{ checked: done }} // its current state
accessibilityHint="Toggles today's check-in" // what happens on activation (optional)
onPress={toggle}
>
| Prop | Purpose | Notes |
|---|---|---|
accessibilityRole |
button, link, checkbox, switch, header, image, tab, search, adjustable, progressbar… |
Changes how it's announced and which gestures apply |
accessibilityLabel |
Spoken name | Defaults to the text content; set it for icon-only controls |
accessibilityState |
{ disabled, selected, checked, busy, expanded } |
Keep in sync with visual state |
accessibilityValue |
{ min, max, now } or { text } |
For sliders, progress, steppers |
accessibilityHint |
Outcome of the action | Short; users can turn hints off |
accessibilityLiveRegion (Android) |
polite / assertive |
Announce changes to this element |
accessibilityElementsHidden (iOS) / importantForAccessibility="no-hide-descendants" (Android) |
Hide from screen readers | For decorative content |
Newer React Native versions also accept web-style ARIA props (role, aria-label,
aria-checked, aria-hidden, …) as aliases. Pick one style per codebase.
Grouping: one card, one announcement¶
By default, a card with an image, a title, a subtitle and a badge can be four separate stops. Group it so it's announced once:
<View accessible accessibilityLabel={`${name}, ${streak} day streak, ${doneToday ? 'done today' : 'not done today'}`}>
<Image source={icon} accessibilityIgnoresInvertColors />
<Text>{name}</Text>
<Text>{streak}-day streak</Text>
{doneToday && <Text>✓</Text>}
</View>
accessible on a container makes it a single element; its children are no longer separately
focusable. Write the combined label in a natural spoken order — not "check mark".
Custom actions instead of gesture-only features¶
Swipe-to-delete, long-press menus and drag-to-reorder are invisible to screen-reader users unless you expose them as accessibility actions:
<View
accessible
accessibilityLabel={habit.name}
accessibilityActions={[
{ name: 'activate', label: 'Toggle done' },
{ name: 'delete', label: 'Delete habit' },
{ name: 'increment', label: 'Move down' },
{ name: 'decrement', label: 'Move up' },
]}
onAccessibilityAction={(e) => {
switch (e.nativeEvent.actionName) {
case 'activate': toggle(habit.id); break;
case 'delete': remove(habit.id); break;
case 'increment': move(habit.id, +1); break;
case 'decrement': move(habit.id, -1); break;
}
}}
>
VoiceOver users reach custom actions with a vertical swipe (the "Actions" rotor); TalkBack users
through its actions menu. activate maps to double-tap, and increment/decrement map to the
adjustable gestures.
Announcements and focus¶
import { AccessibilityInfo, findNodeHandle } from 'react-native';
AccessibilityInfo.announceForAccessibility('Habit saved'); // speak a message without moving focus
// Move screen-reader focus, e.g. to an error after a failed submit:
const node = findNodeHandle(errorRef.current);
if (node) AccessibilityInfo.setAccessibilityFocus(node);
Announce results of actions that don't otherwise change what's focused ("Saved", "3 results"). Don't over-announce.
Text size, contrast and motion¶
- Dynamic type: users can enlarge text a lot.
Textscales with the system setting by default. Don't disable it (allowFontScaling={false}) — instead, cap extreme growth where layout truly breaks withmaxFontSizeMultiplier={1.6}, and design layouts that wrap (lesson L1-08'sfontScalecheck). - Touch targets: at least ~44×44 pt (iOS) / 48×48 dp (Android), via padding or
hitSlop. - Contrast: body text needs a contrast ratio of at least 4.5:1 against its background (WCAG AA). Light grey on white is the most common failure.
- Don't use colour alone to convey state: add an icon or text ("✓ Done").
- Reduced motion and other settings are readable at runtime:
const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled();
const screenReader = await AccessibilityInfo.isScreenReaderEnabled();
const sub = AccessibilityInfo.addEventListener('screenReaderChanged', (on) => setScreenReader(on));
Worked example: an accessible stepper¶
import { Pressable, StyleSheet, Text, View } from 'react-native';
type Props = { label: string; value: number; min: number; max: number; onChange: (v: number) => void };
export function Stepper({ label, value, min, max, onChange }: Props) {
const dec = () => value > min && onChange(value - 1);
const inc = () => value < max && onChange(value + 1);
return (
<View
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ min, max, now: value, text: `${value} per week` }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => (e.nativeEvent.actionName === 'increment' ? inc() : dec())}
style={styles.row}
>
<Text style={styles.label}>{label}</Text>
<View style={styles.controls}>
<Pressable onPress={dec} disabled={value <= min} hitSlop={8} style={styles.button} importantForAccessibility="no">
<Text style={styles.buttonText}>−</Text>
</Pressable>
<Text style={styles.value}>{value}</Text>
<Pressable onPress={inc} disabled={value >= max} hitSlop={8} style={styles.button} importantForAccessibility="no">
<Text style={styles.buttonText}>+</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8, minHeight: 48 },
label: { fontSize: 16, flexShrink: 1 },
controls: { flexDirection: 'row', alignItems: 'center', gap: 12 },
button: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#eef2ff', alignItems: 'center', justifyContent: 'center' },
buttonText: { fontSize: 22, color: '#3730a3', fontWeight: '700' },
value: { minWidth: 24, textAlign: 'center', fontSize: 18, fontVariant: ['tabular-nums'] },
});
To a sighted user it's two buttons and a number. To a screen-reader user it's one adjustable element — "Times per week, 3 per week, adjustable" — that they change by swiping up or down, which is exactly how native steppers behave.
Testing it¶
- Turn on VoiceOver (Settings → Accessibility → VoiceOver, or ask Siri) and TalkBack (Settings → Accessibility → TalkBack) and navigate your app with your eyes closed. It's the single most effective test.
- Accessibility Inspector (Xcode → Open Developer Tool) shows labels, roles and traits on the iOS Simulator; Android's Accessibility Scanner app flags small targets and low contrast.
- In tests, React Native Testing Library queries by role and label (
getByRole('checkbox', { name: 'Drink water' })) — Level 4 uses this, and tests written that way double as accessibility checks.
How It Actually Works¶
React Native's accessibility props are passed to the native views Fabric creates. On iOS they set
UIAccessibility properties — isAccessibilityElement, accessibilityLabel,
accessibilityTraits (from role and state), accessibilityValue and custom actions — which VoiceOver
reads from the native view hierarchy. On Android they populate an AccessibilityNodeInfo for the
view (class name from the role, content description from the label, checkable/checked from state,
custom actions), which TalkBack consumes. accessible={true} on a container makes the native view an
accessibility element and hides its children from the accessibility tree, which is why grouping
works. Because all of this is native, screen readers behave exactly as they do in fully native apps —
provided you supply the information.
Common mistakes¶
- Icon-only buttons with no label — announced as "button" or a file name.
- Roles missing on custom controls — a
Pressablethat looks like a switch but isn't announced as one. accessibilityStatenot updated with visual state.- Every piece of a card focusable separately.
- Gesture-only actions with no accessibility action.
allowFontScaling={false}to "fix" layouts.- Never actually using VoiceOver or TalkBack.
Exercise¶
- Turn on VoiceOver or TalkBack and complete three tasks in your habit app: add a habit, mark it done, delete it. Write down every point where you got stuck or heard something confusing.
- Fix each issue: group habit rows, add roles and states, add accessibility actions for delete and reorder, and announce "Habit added".
- Set your system text size to the largest setting and fix any layout that clips or overlaps.
- Replace the "times per week" text field from Level 2 with the
Stepperand verify it adjusts with swipe up/down under a screen reader.