Accessibility
React Native accessibility — accessible props, screen readers, focus management, dynamic type, testing
Accessibility
Accessibility in React Native is not an afterthought bolted onto a web abstraction — it maps directly to the native platform APIs (UIAccessibility on iOS, AccessibilityNodeInfo on Android). Every accessible prop you set translates to a native accessibility node. This gives you the same power (and responsibility) as a native developer.
Why Accessibility Matters
Legal Requirements
| Regulation | Scope | Key Requirement |
|---|---|---|
| ADA (US) | All public-facing digital services | Effective communication; courts apply WCAG 2.1 AA |
| WCAG 2.1 AA | International standard | Perceivable, operable, understandable, robust |
| European Accessibility Act 2025 | EU member states, effective June 2025 | Products and services must be accessible; applies to mobile apps |
| EN 301 549 | EU public sector | References WCAG 2.1 AA for mobile apps |
| Accessibility Act (NZ) | New Zealand public sector | Requires WCAG 2.1 AA compliance for government services |
Non-compliance carries real risk — US ADA lawsuits exceeded 4,600 in 2023 alone, and the European Accessibility Act creates enforcement across all EU member states.
Business Case
15% of the global population lives with some form of disability. Accessible apps also benefit users situationally — bright sunlight, one-handed use, noisy environments, temporary injuries. Accessibility improvements consistently correlate with better usability metrics for all users.
React Native's Approach
React Native exposes a cross-platform accessibility API that maps to each platform's native layer:
React Native Props iOS Android
───────────────── ───────── ─────────
accessibilityLabel → accessibilityLabel → contentDescription
accessibilityRole → accessibilityTraits → className
accessibilityState → accessibilityTraits → AccessibilityNodeInfo state
accessibilityValue → accessibilityValue → RangeInfo / textThis means your accessibility work produces native-quality screen reader experiences, not web-overlay hacks.
Accessibility Props
Core Props
| Prop | Type | Purpose |
|---|---|---|
accessible | boolean | Groups children into a single focusable element |
accessibilityLabel | string | The text a screen reader announces (the "name") |
accessibilityHint | string | Describes the result of the action (optional, can be disabled by user) |
accessibilityRole | string | Communicates the element type (button, header, link, image, etc.) |
<Pressable
accessible
accessibilityRole="button"
accessibilityLabel="Add to cart"
accessibilityHint="Adds this item to your shopping cart"
onPress={handleAddToCart}
>
<Icon name="cart-plus" />
<Text>Add</Text>
</Pressable>Never use the visible text as a hint. The hint is for the result of an action, not a repetition of the label. VoiceOver users can globally disable hints; if your only meaningful information is in the hint, they will miss it.
accessibilityState
Communicates the current state of a component. This is a plain object, not a string:
<Pressable
accessible
accessibilityRole="checkbox"
accessibilityLabel="Accept terms and conditions"
accessibilityState={{
checked: isChecked,
disabled: isSubmitting,
}}
onPress={() => setChecked(!isChecked)}
>
<Checkbox checked={isChecked} disabled={isSubmitting} />
<Text>Accept terms and conditions</Text>
</Pressable>| State Key | Type | When to Use |
|---|---|---|
disabled | boolean | Element cannot be interacted with |
selected | boolean | Tab, list item, or segment is selected |
checked | boolean | 'mixed' | Checkbox or toggle state |
busy | boolean | Content is loading or updating |
expanded | boolean | Accordion, dropdown, or collapsible is open |
accessibilityValue
For elements that have a value (sliders, progress bars, steppers):
<View
accessible
accessibilityRole="adjustable"
accessibilityLabel="Volume"
accessibilityValue={{
min: 0,
max: 100,
now: volume,
text: `${volume} percent`,
}}
>
<Slider value={volume} onValueChange={setVolume} />
</View>When text is provided, screen readers announce it instead of now/min/max. Use text for values where the number alone is not meaningful (e.g., "Medium" for a three-step preference).
accessibilityActions and onAccessibilityAction
Custom actions exposed to the screen reader's actions menu (VoiceOver rotor, TalkBack custom actions):
function SwipeableMessage({ message, onDelete, onArchive }: Props) {
return (
<View
accessible
accessibilityRole="button"
accessibilityLabel={`Message from ${message.sender}: ${message.subject}`}
accessibilityActions={[
{ name: 'delete', label: 'Delete message' },
{ name: 'archive', label: 'Archive message' },
{ name: 'activate', label: 'Open message' },
]}
onAccessibilityAction={(event) => {
switch (event.nativeEvent.actionName) {
case 'delete':
onDelete(message.id);
break;
case 'archive':
onArchive(message.id);
break;
case 'activate':
navigation.navigate('MessageDetail', { id: message.id });
break;
}
}}
>
<MessageRow message={message} />
</View>
);
}The activate action name is special. It maps to the default double-tap gesture on both platforms. If you define accessibilityActions without including activate, double-tap may stop working for that element. Always include it when the element has a primary tap action.
Platform-Specific Props
<View
// Android only: controls whether the view fires accessibility events
importantForAccessibility="yes" // 'yes' | 'no' | 'no-hide-descendants' | 'auto'
// Android only: live region announces content changes
accessibilityLiveRegion="polite" // 'polite' | 'assertive' | 'none'
// iOS only: hides the element and all children from VoiceOver
accessibilityElementsHidden={false}
/>Use importantForAccessibility="no-hide-descendants" to hide decorative containers (e.g., background gradients, purely visual overlays) from the accessibility tree entirely.
Accessible Custom Component
Putting it together — a production-quality accessible toggle:
import { Pressable, Text, StyleSheet, type AccessibilityState } from 'react-native';
interface ToggleProps {
label: string;
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
}
export function Toggle({ label, value, onChange, disabled = false }: ToggleProps) {
const a11yState: AccessibilityState = {
checked: value,
disabled,
};
return (
<Pressable
accessible
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityState={a11yState}
onPress={() => {
if (!disabled) onChange(!value);
}}
style={[styles.container, disabled && styles.disabled]}
>
<Text style={styles.label}>{label}</Text>
<View
style={[
styles.track,
value ? styles.trackOn : styles.trackOff,
]}
>
<View style={[styles.thumb, value && styles.thumbOn]} />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flexDirection: 'row', alignItems: 'center', padding: 12 },
disabled: { opacity: 0.5 },
label: { flex: 1, fontSize: 16 },
track: { width: 48, height: 28, borderRadius: 14, justifyContent: 'center', padding: 2 },
trackOn: { backgroundColor: '#34C759' },
trackOff: { backgroundColor: '#E5E5EA' },
thumb: { width: 24, height: 24, borderRadius: 12, backgroundColor: '#fff' },
thumbOn: { alignSelf: 'flex-end' },
});Screen Readers
VoiceOver (iOS) vs TalkBack (Android)
| Behavior | VoiceOver | TalkBack |
|---|---|---|
| Navigation gesture | Swipe left/right to move between elements | Swipe left/right |
| Activation | Double-tap | Double-tap |
| Scrolling | Three-finger swipe | Two-finger swipe |
| Custom actions | Rotor (two-finger rotate) | Local context menu (swipe up then right) |
| Escape/back | Two-finger Z-gesture | Gesture varies by version |
| Announcements | Interrupts current speech by default | Queues by default (polite) |
| Heading navigation | Rotor → Headings | Heading navigation shortcut |
Test on both platforms. VoiceOver and TalkBack handle reading order, grouping, and announcement timing differently. A screen that works well with VoiceOver can be confusing with TalkBack if you rely on platform-specific grouping behavior.
Reading Order
React Native's default reading order follows the component tree order, which usually matches visual order. When it does not (absolute positioning, complex layouts), use the accessibilityOrder prop (React Native 0.73+) to specify explicit reading order:
<View accessibilityOrder={[headerRef, priceRef, descriptionRef, buyButtonRef]}>
<View ref={priceRef} style={styles.priceOverlay}>
<Text>$29.99</Text>
</View>
<View ref={headerRef} style={styles.header}>
<Text accessibilityRole="header">Product Name</Text>
</View>
<View ref={descriptionRef}>
<Text>Product description text.</Text>
</View>
<Pressable ref={buyButtonRef} accessibilityRole="button">
<Text>Buy Now</Text>
</Pressable>
</View>Live Announcements
For dynamic content updates (toast messages, form errors, loading completion):
import { AccessibilityInfo } from 'react-native';
function showToast(message: string) {
setToastVisible(true);
setToastMessage(message);
// Announce to screen reader users immediately
AccessibilityInfo.announceForAccessibility(message);
}Conditional UI for Screen Reader Users
import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';
function useScreenReader(): boolean {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
AccessibilityInfo.isScreenReaderEnabled().then(setEnabled);
const subscription = AccessibilityInfo.addEventListener(
'screenReaderChanged',
setEnabled,
);
return () => subscription.remove();
}, []);
return enabled;
}
// Usage: provide alternative UI when gestures are not usable
function CardStack() {
const isScreenReader = useScreenReader();
if (isScreenReader) {
// Render a simple list instead of a swipeable card stack
return <FlatList data={cards} renderItem={renderAccessibleCard} />;
}
return <SwipeableCardStack cards={cards} />;
}Common Screen Reader Issues
Pressable without a label — the most frequent accessibility bug. If a Pressable contains only an icon or image, it is announced as "button" with no context:
// Bad: TalkBack announces "button, double-tap to activate"
<Pressable onPress={handleClose}>
<Icon name="close" />
</Pressable>
// Good: announces "Close, button, double-tap to activate"
<Pressable
onPress={handleClose}
accessibilityRole="button"
accessibilityLabel="Close"
>
<Icon name="close" />
</Pressable>FlatList item accessibility — each list item should be independently focusable with a meaningful label:
const renderItem = useCallback(({ item }: { item: Order }) => (
<Pressable
accessible
accessibilityRole="button"
accessibilityLabel={`Order ${item.id}, ${item.status}, ${formatCurrency(item.total)}`}
accessibilityHint="Opens order details"
onPress={() => navigation.navigate('OrderDetail', { orderId: item.id })}
>
<OrderRow order={item} />
</Pressable>
), [navigation]);Modal focus trap — when a modal opens, screen reader focus should be constrained within it. React Native's <Modal> component handles this on iOS but needs help on Android:
<Modal
visible={isVisible}
onRequestClose={handleClose} // required for Android back button
accessibilityViewIsModal={true} // iOS: traps VoiceOver focus inside modal
>
<View
importantForAccessibility="yes" // Android: ensures modal content is focused
>
{/* Modal content */}
</View>
</Modal>Focus Management
Programmatic Focus
After navigation transitions, content updates, or error states, move focus to the relevant element:
import { useRef } from 'react';
import { AccessibilityInfo, findNodeHandle, View, Text } from 'react-native';
function OrderConfirmation() {
const headingRef = useRef<View>(null);
useEffect(() => {
// Move focus to the confirmation heading after mount
const node = findNodeHandle(headingRef.current);
if (node) {
AccessibilityInfo.setAccessibilityFocus(node);
}
}, []);
return (
<View ref={headingRef} accessible accessibilityRole="header">
<Text style={styles.heading}>Order Confirmed</Text>
</View>
);
}Focus Management After Navigation
With @react-navigation/native, screen focus is managed automatically — when a new screen is pushed, VoiceOver/TalkBack focus moves to it. However, you may need to direct focus to a specific element:
import { useFocusEffect } from '@react-navigation/native';
function SearchScreen() {
const searchInputRef = useRef<TextInput>(null);
useFocusEffect(
useCallback(() => {
// Focus the search input when this screen comes into view
const timeout = setTimeout(() => {
const node = findNodeHandle(searchInputRef.current);
if (node) {
AccessibilityInfo.setAccessibilityFocus(node);
}
}, 100); // small delay lets the screen transition finish
return () => clearTimeout(timeout);
}, []),
);
return <TextInput ref={searchInputRef} accessibilityLabel="Search orders" />;
}The 100ms delay is intentional. setAccessibilityFocus can fail silently if called during a screen transition animation. A short delay ensures the target element is mounted and laid out before focus is requested.
Accessible Form with Error Focus Management
import { useRef, useState } from 'react';
import {
AccessibilityInfo,
findNodeHandle,
ScrollView,
Text,
TextInput,
Pressable,
View,
StyleSheet,
} from 'react-native';
interface FormErrors {
email?: string;
password?: string;
}
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const errorSummaryRef = useRef<View>(null);
function validate(): boolean {
const next: FormErrors = {};
if (!email.includes('@')) next.email = 'Enter a valid email address';
if (password.length < 8) next.password = 'Password must be at least 8 characters';
setErrors(next);
if (Object.keys(next).length > 0) {
// Move focus to error summary so screen reader announces it
const node = findNodeHandle(errorSummaryRef.current);
if (node) AccessibilityInfo.setAccessibilityFocus(node);
return false;
}
return true;
}
const errorMessages = Object.values(errors).filter(Boolean);
return (
<ScrollView contentContainerStyle={styles.form}>
{errorMessages.length > 0 && (
<View
ref={errorSummaryRef}
accessible
accessibilityRole="alert"
accessibilityLabel={`${errorMessages.length} errors: ${errorMessages.join('. ')}`}
style={styles.errorSummary}
>
<Text style={styles.errorTitle}>Please fix the following:</Text>
{errorMessages.map((msg) => (
<Text key={msg} style={styles.errorText}>{msg}</Text>
))}
</View>
)}
<Text nativeID="emailLabel" style={styles.label}>Email</Text>
<TextInput
ref={emailRef}
accessibilityLabelledBy="emailLabel"
accessibilityErrorMessage={errors.email}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
style={[styles.input, errors.email && styles.inputError]}
/>
{errors.email && (
<Text accessibilityRole="alert" style={styles.errorText}>
{errors.email}
</Text>
)}
<Text nativeID="passwordLabel" style={styles.label}>Password</Text>
<TextInput
ref={passwordRef}
accessibilityLabelledBy="passwordLabel"
accessibilityErrorMessage={errors.password}
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
returnKeyType="done"
onSubmitEditing={() => { if (validate()) handleLogin(); }}
style={[styles.input, errors.password && styles.inputError]}
/>
{errors.password && (
<Text accessibilityRole="alert" style={styles.errorText}>
{errors.password}
</Text>
)}
<Pressable
accessible
accessibilityRole="button"
accessibilityLabel="Sign in"
onPress={() => { if (validate()) handleLogin(); }}
style={styles.button}
>
<Text style={styles.buttonText}>Sign In</Text>
</Pressable>
</ScrollView>
);
}
const styles = StyleSheet.create({
form: { padding: 16, gap: 8 },
label: { fontSize: 16, fontWeight: '600' },
input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
inputError: { borderColor: '#D32F2F' },
errorSummary: { backgroundColor: '#FFEBEE', padding: 12, borderRadius: 8, marginBottom: 8 },
errorTitle: { fontWeight: '700', color: '#D32F2F', marginBottom: 4 },
errorText: { color: '#D32F2F', fontSize: 14 },
button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, alignItems: 'center', marginTop: 8 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});Dynamic Type and Text Scaling
allowFontScaling
React Native's <Text> component respects the system font size by default (allowFontScaling={true}). Do not disable it globally.
// Bad: disabling font scaling breaks accessibility
<Text allowFontScaling={false}>Price: $29.99</Text>
// Good: allow scaling with an upper bound to prevent layout breakage
<Text maxFontSizeMultiplier={1.5}>Price: $29.99</Text>Setting allowFontScaling={false} is a WCAG failure. Users who need larger text have configured it at the system level. Overriding their preference means your text is unreadable to them. Use maxFontSizeMultiplier instead when you need to prevent extreme scaling from breaking layout.
maxFontSizeMultiplier Guidelines
| Element | Recommended Max | Rationale |
|---|---|---|
| Body text | No limit (default) | Must scale freely |
| Navigation headers | 1.5 | Prevents header overflow |
| Tab bar labels | 1.3 | Constrained space |
| Button labels | 1.5 | Must remain tappable |
| Badge / counter text | 1.2 | Fixed container size |
Responsive Layout at Large Text Sizes
import { PixelRatio, useWindowDimensions } from 'react-native';
function AdaptiveHeader({ title, subtitle }: Props) {
const fontScale = PixelRatio.getFontScale();
const { width } = useWindowDimensions();
// Switch from horizontal to vertical layout at large text sizes
const isLargeText = fontScale > 1.3;
return (
<View style={[styles.header, isLargeText && styles.headerVertical]}>
<Text
style={styles.title}
accessibilityRole="header"
numberOfLines={isLargeText ? 2 : 1}
>
{title}
</Text>
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
headerVertical: { flexDirection: 'column', alignItems: 'flex-start' },
title: { fontSize: 18, fontWeight: '700' },
subtitle: { fontSize: 14, color: '#666' },
});Testing at Different Text Sizes
| Platform | How to change |
|---|---|
| iOS Simulator | Settings > Accessibility > Display & Text Size > Larger Text |
| iOS Device | Settings > Accessibility > Display & Text Size > Larger Text (enable Larger Accessibility Sizes for extreme) |
| Android | Settings > Accessibility > Font size (or Display size) |
| Android Emulator | Same as device; also via adb shell settings put system font_scale 2.0 |
Test at minimum 1.0x (default), 1.5x (common), and the maximum setting. Layouts that break at 2x text scale will break for real users.
Color and Contrast
WCAG Contrast Ratios
| Level | Text | Large Text (18pt+ or 14pt bold) | UI Components |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | Not defined |
Dark Mode Considerations
import { useColorScheme } from 'react-native';
function useAccessibleColors() {
const scheme = useColorScheme();
return {
text: scheme === 'dark' ? '#E5E5E7' : '#1C1C1E',
textSecondary: scheme === 'dark' ? '#ABABAB' : '#636366',
background: scheme === 'dark' ? '#1C1C1E' : '#FFFFFF',
error: scheme === 'dark' ? '#FF6B6B' : '#D32F2F', // both pass AA contrast
border: scheme === 'dark' ? '#3A3A3C' : '#C7C7CC',
};
}Do not rely on color alone to convey information. Error states, required fields, and status indicators should combine color with icons, text, or patterns. Approximately 8% of males have some form of color vision deficiency.
accessibilityIgnoresInvertColors
Users with light sensitivity may use iOS Smart Invert. Mark images and videos so they are not color-inverted:
<Image
source={{ uri: productImageUrl }}
accessibilityIgnoresInvertColors
accessibilityLabel="Blue running shoe, side view"
style={styles.productImage}
/>Touch Target Size
Minimum Size Requirements
WCAG 2.1 AA requires interactive targets of at least 44x44 CSS pixels. Android Material guidelines specify 48x48 dp. Apply the stricter standard: minimum 48x48 dp.
// Bad: icon button too small
<Pressable onPress={handleInfo} style={{ padding: 4 }}>
<Icon name="info" size={16} />
</Pressable>
// Good: adequate touch target with hitSlop
<Pressable
onPress={handleInfo}
accessibilityRole="button"
accessibilityLabel="More information"
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={{ padding: 8 }}
>
<Icon name="info" size={24} />
</Pressable>The hitSlop prop extends the tappable area without affecting layout. Combine it with adequate padding to reach 48x48 dp total.
Spacing Between Targets
Adjacent interactive elements need enough spacing that users with motor impairments can tap accurately. Minimum 8dp gap between touch targets. For critical actions (delete, submit payment), use larger spacing.
Testing Accessibility
React Native Testing Library
The @testing-library/react-native library provides accessibility-first queries:
import { render, screen, fireEvent } from '@testing-library/react-native';
test('toggle changes state and announces correctly', () => {
const onChange = jest.fn();
render(<Toggle label="Notifications" value={false} onChange={onChange} />);
const toggle = screen.getByRole('switch', { name: 'Notifications' });
expect(toggle).toHaveAccessibilityState({ checked: false });
fireEvent.press(toggle);
expect(onChange).toHaveBeenCalledWith(true);
});
test('login form shows accessible error messages', () => {
render(<LoginForm />);
fireEvent.press(screen.getByRole('button', { name: 'Sign in' }));
// Verify errors are announced via role="alert"
expect(screen.getByRole('alert')).toBeTruthy();
// Verify fields can be found by their label
expect(screen.getByLabelText('Email')).toBeTruthy();
expect(screen.getByLabelText('Password')).toBeTruthy();
});Query Priority
Use this query priority to ensure your tests verify real accessibility:
| Priority | Query | Tests |
|---|---|---|
| 1 | getByRole | Element has correct role |
| 2 | getByLabelText | Element has correct label |
| 3 | getByA11yHint | Hint is present (lower priority since users can disable hints) |
| 4 | getByText | Visible text (does not test screen reader experience) |
| 5 | getByTestId | Last resort; tests nothing about accessibility |
Manual Testing Protocol
Automated tests catch missing labels and roles. They do not catch confusing reading order, poor grouping, or awkward navigation flow. Manual testing is required.
VoiceOver walkthrough (iOS):
- Enable VoiceOver: Settings > Accessibility > VoiceOver (or triple-click side button)
- Swipe right through every element on each screen
- Verify: every interactive element announces its name, role, and state
- Verify: reading order matches visual order
- Double-tap to activate each button; confirm the result is announced
- Test with the rotor: navigate by headings, by links, by buttons
- Dismiss modals and verify focus returns to the trigger element
TalkBack walkthrough (Android):
- Enable TalkBack: Settings > Accessibility > TalkBack
- Swipe right through every element
- Test custom actions via the local context menu (swipe up then right)
- Verify live region announcements (toasts, loading states)
- Test back button behavior inside modals
Tooling
| Tool | Platform | What It Catches |
|---|---|---|
| Accessibility Inspector | iOS Simulator (Xcode) | Missing labels, contrast issues, element hierarchy |
| Android Accessibility Scanner | Android device/emulator | Touch target size, contrast, missing labels |
| eslint-plugin-react-native-a11y | CI/Editor | Missing labels, roles, states at lint time |
| Detox | Both | End-to-end flows with accessibility assertions |
| axe DevTools Mobile | Both | Automated accessibility scans with WCAG mapping |
ESLint Static Analysis
npm install --save-dev eslint-plugin-react-native-a11y// .eslintrc.js
module.exports = {
plugins: ['react-native-a11y'],
extends: ['plugin:react-native-a11y/all'],
};This catches missing accessibilityLabel on Pressable, TouchableOpacity, and Image components at build time.
Anti-Patterns
Setting accessible={false} on Interactive Elements
// Bad: removes the element from the accessibility tree entirely
<Pressable accessible={false} onPress={handlePress}>
<Text>Submit</Text>
</Pressable>
// Good: interactive elements must be accessible
<Pressable
accessible
accessibilityRole="button"
accessibilityLabel="Submit"
onPress={handlePress}
>
<Text>Submit</Text>
</Pressable>Labeling the Container Instead of Children
// Bad: screen reader announces the entire container as one element,
// user cannot navigate to individual items
<View accessible accessibilityLabel="Order list with 3 items">
<OrderRow order={orders[0]} />
<OrderRow order={orders[1]} />
<OrderRow order={orders[2]} />
</View>
// Good: each item is independently focusable
<View>
{orders.map((order) => (
<Pressable
key={order.id}
accessible
accessibilityRole="button"
accessibilityLabel={`Order ${order.id}, ${order.status}`}
onPress={() => selectOrder(order.id)}
>
<OrderRow order={order} />
</Pressable>
))}
</View>Ignoring Reduced Motion
Users who experience vestibular disorders enable "Reduce Motion" at the system level. Respect it:
import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';
import Animated, { withSpring, withTiming, useSharedValue } from 'react-native-reanimated';
function useReduceMotion(): boolean {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled().then(setEnabled);
const subscription = AccessibilityInfo.addEventListener(
'reduceMotionChanged',
setEnabled,
);
return () => subscription.remove();
}, []);
return enabled;
}
function AnimatedCard({ children }: { children: ReactNode }) {
const reduceMotion = useReduceMotion();
const translateY = useSharedValue(100);
useEffect(() => {
if (reduceMotion) {
// Skip animation entirely — just show it
translateY.value = 0;
} else {
translateY.value = withSpring(0, { damping: 15 });
}
}, [reduceMotion]);
return <Animated.View style={{ transform: [{ translateY }] }}>{children}</Animated.View>;
}Reanimated v3 provides useReducedMotion() out of the box. Prefer it over the manual approach above: const reduceMotion = useReducedMotion();. It runs as a worklet-compatible shared value on the UI thread.
Platform-Specific Props Without Cross-Platform Testing
// Bad: only works on iOS, TalkBack users get nothing
<View accessibilityElementsHidden={isDecorative} />
// Good: handle both platforms
<View
accessibilityElementsHidden={isDecorative} // iOS
importantForAccessibility={isDecorative ? 'no-hide-descendants' : 'auto'} // Android
/>Accessibility Checklist
Use this checklist before every release. Each item maps to a WCAG 2.1 AA success criterion or platform guideline.
| Category | Check | WCAG SC |
|---|---|---|
| Labels | Every interactive element has an accessibilityLabel | 1.1.1, 4.1.2 |
| Labels | Labels are descriptive, not generic ("Submit order" not "Button") | 2.4.6 |
| Labels | Images have descriptive labels or are marked decorative | 1.1.1 |
| Roles | Correct accessibilityRole on all interactive elements | 4.1.2 |
| State | accessibilityState reflects current state (checked, disabled, expanded) | 4.1.2 |
| Focus | Focus moves to new content after navigation or modal open | 2.4.3 |
| Focus | Focus returns to trigger element when modal/dialog closes | 2.4.3 |
| Focus | No keyboard/focus traps (except intentional modal traps) | 2.1.2 |
| Reading Order | Screen reader reading order matches visual layout | 1.3.2 |
| Touch Targets | All interactive elements are at least 48x48 dp | 2.5.8 |
| Text Scaling | UI is usable at 200% text scale | 1.4.4 |
| Text Scaling | allowFontScaling is not set to false on any Text | 1.4.4 |
| Contrast | Text contrast ratio is at least 4.5:1 (3:1 for large text) | 1.4.3 |
| Color | Information is not conveyed by color alone | 1.4.1 |
| Motion | Animations respect isReduceMotionEnabled | 2.3.3 |
| Live Regions | Dynamic content changes are announced to screen readers | 4.1.3 |
| Errors | Form errors are announced and focusable | 3.3.1 |
| Platform | Tested with VoiceOver (iOS) AND TalkBack (Android) | - |
| Dark Mode | Contrast ratios hold in both light and dark mode | 1.4.3 |
| Invert Colors | Images use accessibilityIgnoresInvertColors where needed | 1.4.1 |