React Native Accessibility Guide for Senior Developers
9 min readAccessibility in React Native is not just slapping accessibilityLabel on a TouchableOpacity and calling it done. A screen reader user should be able to navigate your app efficiently, understand context, and complete tasks — just like any sighted user.
This guide is for developers who already know the basics and want to go deeper.
1. accessibilityLabel vs accessibilityHint — Know the Difference
accessibilityLabel replaces the visible text read by the screen reader. accessibilityHint provides supplemental context about what happens when an action is performed.
The rule: the label should answer what it is, the hint should answer what it does.
// ❌ Redundant hint — already implied by the label
<TouchableOpacity
accessibilityLabel="Delete"
accessibilityHint="Deletes the item"
>
<Text>Delete</Text>
</TouchableOpacity>
// ✅ Label names the target, hint explains the outcome
<TouchableOpacity
accessibilityLabel="Delete message from Sarah"
accessibilityHint="Moves it to trash. This cannot be undone."
>
<Text>Delete</Text>
</TouchableOpacity>
VoiceOver reads: "Delete message from Sarah. Button. Moves it to trash. This cannot be undone."
Hints are read after a pause, and users can disable them in system settings. Never put critical information only in a hint.
2. accessibilityRole — Use Semantics, Not Just Interactivity
React Native's accessibilityRole maps to native platform semantics (UIAccessibilityTraits on iOS, ViewCompat roles on Android). It tells the screen reader what type of element this is.
// ❌ Works visually, but screen reader announces "Button" for everything
<TouchableOpacity onPress={toggleMenu}>
<Text>Menu</Text>
</TouchableOpacity>
// ✅ Correct roles communicate native semantics
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Open navigation menu"
onPress={toggleMenu}
>
<Text>Menu</Text>
</TouchableOpacity>
// For links that navigate outside the app
<TouchableOpacity
accessibilityRole="link"
accessibilityLabel="View privacy policy"
onPress={() => Linking.openURL(PRIVACY_URL)}
>
<Text>Privacy Policy</Text>
</TouchableOpacity>
// For toggle switches built with Pressable
<Pressable
accessibilityRole="switch"
accessibilityState={{ checked: isEnabled }}
onPress={toggle}
>
<CustomToggle enabled={isEnabled} />
</Pressable>
Common roles you'll need: button, link, image, header, switch, checkbox, radio, tab, combobox, progressbar, slider, spinbutton, summary, none.
3. accessibilityState — Communicate Dynamic State Changes
Static labels are not enough for interactive components. Use accessibilityState to reflect live UI state to the accessibility tree.
interface AccordionProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function Accordion({ title, isExpanded, onToggle, children }: AccordionProps) {
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel={title}
accessibilityState={{ expanded: isExpanded }}
onPress={onToggle}
>
<Text>{title}</Text>
<Icon name={isExpanded ? 'chevron-up' : 'chevron-down'} />
</Pressable>
{isExpanded && (
<View accessible={false}>
{children}
</View>
)}
</>
);
}
Available state keys: disabled, selected, checked (boolean | 'mixed'), busy, expanded.
The busy state is particularly useful during async operations — set it while data is loading so TalkBack/VoiceOver announces "Loading" rather than going silent.
<FlatList
accessible
accessibilityLabel="Search results"
accessibilityState={{ busy: isLoading }}
data={results}
renderItem={renderItem}
/>
4. accessibilityValue — Custom Range and Progress Controls
For sliders, progress bars, and any component representing a range, accessibilityValue provides min, max, now, and text.
interface VolumeSliderProps {
volume: number; // 0–100
onChange: (v: number) => void;
}
function VolumeSlider({ volume, onChange }: VolumeSliderProps) {
return (
<Slider
minimumValue={0}
maximumValue={100}
value={volume}
onValueChange={onChange}
accessibilityRole="adjustable"
accessibilityLabel="Volume"
accessibilityValue={{
min: 0,
max: 100,
now: volume,
text: `${volume} percent`,
}}
// Required for VoiceOver swipe-up/swipe-down adjustments
accessibilityIncrementLabel="Increase volume"
accessibilityDecrementLabel="Decrease volume"
/>
);
}
Without accessibilityValue, a custom slider reads as a generic element with no context. With it, VoiceOver announces: "Volume. Adjustable. 65 percent."
5. Focus Management — Control the Accessibility Focus Programmatically
When a modal opens, new content appears, or navigation changes the screen, you must move accessibility focus to the right element. Without this, screen reader users are left stranded.
import { AccessibilityInfo, findNodeHandle, useRef, useEffect } from 'react';
function ConfirmationModal({ visible, title, onClose }: ModalProps) {
const titleRef = useRef<Text>(null);
useEffect(() => {
if (visible && titleRef.current) {
// Move focus to modal title when it opens
const node = findNodeHandle(titleRef.current);
if (node) {
AccessibilityInfo.setAccessibilityFocus(node);
}
}
}, [visible]);
return (
<Modal visible={visible} transparent animationType="fade">
<View
accessible
accessibilityViewIsModal // Traps focus inside on iOS
style={styles.overlay}
>
<Text ref={titleRef} accessibilityRole="header">
{title}
</Text>
{/* modal content */}
<Pressable
accessibilityRole="button"
accessibilityLabel="Close modal"
onPress={onClose}
>
<Text>Close</Text>
</Pressable>
</View>
</Modal>
);
}
accessibilityViewIsModal on iOS traps VoiceOver within the modal container. On Android, use importantForAccessibility="yes" on the modal and "no-hide-descendants" on background content.
// Android: hide background content from accessibility tree
<View importantForAccessibility={isModalOpen ? 'no-hide-descendants' : 'auto'}>
<AppContent />
</View>
6. The accessible Prop — Grouping vs Hiding
The accessible prop on a View merges all child elements into a single accessible node. This is a double-edged sword.
// ✅ Good grouping: card that reads as one unit
<View
accessible
accessibilityRole="button"
accessibilityLabel="Rome, Italy. 4.8 stars. From £120 per night."
onTouchEnd={navigateToListing}
>
<Image source={imageSource} accessibilityElementsHidden />
<Text>Rome, Italy</Text>
<StarRating value={4.8} />
<Text>From £120 per night</Text>
</View>
// ❌ Bad grouping: hides interactive children the user needs to reach
<View accessible>
<TextInput placeholder="Search" /> {/* unreachable by screen reader */}
<Pressable onPress={clearSearch}> {/* unreachable by screen reader */}
<Icon name="close" />
</Pressable>
</View>
Use accessibilityElementsHidden (iOS) and importantForAccessibility="no-hide-descendants" (Android) to exclude decorative elements without wrapping everything in an accessible group.
// Cross-platform helper
function HiddenFromAccessibility({ children }: { children: React.ReactNode }) {
return (
<View
accessibilityElementsHidden // iOS
importantForAccessibility="no-hide-descendants" // Android
>
{children}
</View>
);
}
7. Touch Target Size — WCAG 2.5.5 in Practice
WCAG 2.5.5 requires interactive targets to be at least 44×44 points. Small icon buttons are the most common violation.
// ❌ Icon button with tiny hit area
<TouchableOpacity onPress={onLike}>
<Icon name="heart" size={20} />
</TouchableOpacity>
// ✅ Option 1: expand with padding
<TouchableOpacity
onPress={onLike}
style={{ padding: 12 }} // 20 + 24 = 44pt total
accessibilityRole="button"
accessibilityLabel={isLiked ? "Unlike post" : "Like post"}
>
<Icon name="heart" size={20} />
</TouchableOpacity>
// ✅ Option 2: use hitSlop when you can't change layout
<TouchableOpacity
onPress={onLike}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
accessibilityRole="button"
accessibilityLabel="Like post"
>
<Icon name="heart" size={20} />
</TouchableOpacity>
hitSlop extends the touch area without affecting layout. Prefer explicit padding when possible — it's more predictable across devices and density levels.
8. AccessibilityInfo API — Respond to System Accessibility Settings
Senior developers should build apps that adapt to the user's system preferences, not just expose ARIA attributes.
import { AccessibilityInfo, useEffect, useState } from 'react';
function useAccessibilitySettings() {
const [screenReaderEnabled, setScreenReaderEnabled] = useState(false);
const [reduceMotionEnabled, setReduceMotionEnabled] = useState(false);
useEffect(() => {
// Get initial values
AccessibilityInfo.isScreenReaderEnabled().then(setScreenReaderEnabled);
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotionEnabled);
// Subscribe to changes
const srSubscription = AccessibilityInfo.addEventListener(
'screenReaderChanged',
setScreenReaderEnabled
);
const motionSubscription = AccessibilityInfo.addEventListener(
'reduceMotionChanged',
setReduceMotionEnabled
);
return () => {
srSubscription.remove();
motionSubscription.remove();
};
}, []);
return { screenReaderEnabled, reduceMotionEnabled };
}
// Usage: conditionally disable animations
function AnimatedCard() {
const { reduceMotionEnabled } = useAccessibilitySettings();
return (
<Animated.View
style={[
styles.card,
!reduceMotionEnabled && animatedStyle, // skip animation if requested
]}
/>
);
}
Also useful: isBoldTextEnabled, isGrayscaleEnabled, isInvertColorsEnabled. Use these to make visual adjustments that respect the user's OS-level preferences.
9. Custom Accessibility Actions — Interactive Patterns Beyond Tap
For complex widgets like swipeable cards, drag handles, or list items with multiple actions, use accessibilityActions to expose custom gestures to screen reader users.
interface EmailRowProps {
subject: string;
onOpen: () => void;
onArchive: () => void;
onDelete: () => void;
}
function EmailRow({ subject, onOpen, onArchive, onDelete }: EmailRowProps) {
const actions = [
{ name: 'activate', label: 'Open email' }, // replaces default double-tap
{ name: 'archive', label: 'Archive email' },
{ name: 'delete', label: 'Delete email' },
];
const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
switch (event.nativeEvent.actionName) {
case 'activate': onOpen(); break;
case 'archive': onArchive(); break;
case 'delete': onDelete(); break;
}
};
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={subject}
accessibilityHint="Double tap to open. Swipe up or down for more options."
accessibilityActions={actions}
onAccessibilityAction={handleAccessibilityAction}
onPress={onOpen}
>
<Text>{subject}</Text>
</Pressable>
);
}
VoiceOver surfaces custom actions via the rotor (swipe up/down). TalkBack uses a long-press menu. This is what allows screen reader users to access the same swipe gestures that sighted users perform.
10. Testing — Automate What You Can, Audit What You Can't
Accessibility bugs are regressions. Treat them like functional bugs and write tests.
Unit testing with @testing-library/react-native:
import { render, screen } from '@testing-library/react-native';
import { EmailRow } from './EmailRow';
describe('EmailRow accessibility', () => {
it('has a label, role, and custom actions', () => {
render(
<EmailRow
subject="Meeting at 3pm"
onOpen={jest.fn()}
onArchive={jest.fn()}
onDelete={jest.fn()}
/>
);
const row = screen.getByRole('button', { name: 'Meeting at 3pm' });
expect(row).toBeTruthy();
// Verify custom actions are registered
expect(row.props.accessibilityActions).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'archive' }),
expect.objectContaining({ name: 'delete' }),
])
);
});
it('triggers onDelete via accessibility action', () => {
const onDelete = jest.fn();
render(
<EmailRow subject="Test" onOpen={jest.fn()} onArchive={jest.fn()} onDelete={onDelete} />
);
const row = screen.getByRole('button');
fireEvent(row, 'accessibilityAction', { nativeEvent: { actionName: 'delete' } });
expect(onDelete).toHaveBeenCalledTimes(1);
});
});
What to manually audit with TalkBack / VoiceOver:
- Navigate the entire screen using swipe-right only. Is the reading order logical?
- Every interactive element should be reachable and announce its role, state, and label.
- After a modal opens, does focus move into it? After it closes, does focus return to the trigger?
- Forms: are field labels announced before the input? Are error messages linked to the field?
- Animations: do they pause under Reduce Motion?
Summary
| Pattern | Key Prop / API |
|---|---|
| Descriptive labels and hints | accessibilityLabel, accessibilityHint |
| Semantic element type | accessibilityRole |
| Live state changes | accessibilityState |
| Range and progress values | accessibilityValue |
| Focus management | AccessibilityInfo.setAccessibilityFocus |
| Group vs hide | accessible, accessibilityElementsHidden |
| Touch target size | hitSlop, padding |
| System preferences | AccessibilityInfo event listeners |
| Multi-action widgets | accessibilityActions, onAccessibilityAction |
| Regression prevention | @testing-library/react-native |
Accessibility done right is invisible to users who don't need it, and essential to those who do. The patterns above move you from compliance to craft.