Steven's Knowledge

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

RegulationScopeKey Requirement
ADA (US)All public-facing digital servicesEffective communication; courts apply WCAG 2.1 AA
WCAG 2.1 AAInternational standardPerceivable, operable, understandable, robust
European Accessibility Act 2025EU member states, effective June 2025Products and services must be accessible; applies to mobile apps
EN 301 549EU public sectorReferences WCAG 2.1 AA for mobile apps
Accessibility Act (NZ)New Zealand public sectorRequires 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 / text

This means your accessibility work produces native-quality screen reader experiences, not web-overlay hacks.

Accessibility Props

Core Props

PropTypePurpose
accessiblebooleanGroups children into a single focusable element
accessibilityLabelstringThe text a screen reader announces (the "name")
accessibilityHintstringDescribes the result of the action (optional, can be disabled by user)
accessibilityRolestringCommunicates 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 KeyTypeWhen to Use
disabledbooleanElement cannot be interacted with
selectedbooleanTab, list item, or segment is selected
checkedboolean | 'mixed'Checkbox or toggle state
busybooleanContent is loading or updating
expandedbooleanAccordion, 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)

BehaviorVoiceOverTalkBack
Navigation gestureSwipe left/right to move between elementsSwipe left/right
ActivationDouble-tapDouble-tap
ScrollingThree-finger swipeTwo-finger swipe
Custom actionsRotor (two-finger rotate)Local context menu (swipe up then right)
Escape/backTwo-finger Z-gestureGesture varies by version
AnnouncementsInterrupts current speech by defaultQueues by default (polite)
Heading navigationRotor → HeadingsHeading 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

ElementRecommended MaxRationale
Body textNo limit (default)Must scale freely
Navigation headers1.5Prevents header overflow
Tab bar labels1.3Constrained space
Button labels1.5Must remain tappable
Badge / counter text1.2Fixed 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

PlatformHow to change
iOS SimulatorSettings > Accessibility > Display & Text Size > Larger Text
iOS DeviceSettings > Accessibility > Display & Text Size > Larger Text (enable Larger Accessibility Sizes for extreme)
AndroidSettings > Accessibility > Font size (or Display size)
Android EmulatorSame 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

LevelTextLarge Text (18pt+ or 14pt bold)UI Components
AA4.5:13:13:1
AAA7:14.5:1Not 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:

PriorityQueryTests
1getByRoleElement has correct role
2getByLabelTextElement has correct label
3getByA11yHintHint is present (lower priority since users can disable hints)
4getByTextVisible text (does not test screen reader experience)
5getByTestIdLast 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):

  1. Enable VoiceOver: Settings > Accessibility > VoiceOver (or triple-click side button)
  2. Swipe right through every element on each screen
  3. Verify: every interactive element announces its name, role, and state
  4. Verify: reading order matches visual order
  5. Double-tap to activate each button; confirm the result is announced
  6. Test with the rotor: navigate by headings, by links, by buttons
  7. Dismiss modals and verify focus returns to the trigger element

TalkBack walkthrough (Android):

  1. Enable TalkBack: Settings > Accessibility > TalkBack
  2. Swipe right through every element
  3. Test custom actions via the local context menu (swipe up then right)
  4. Verify live region announcements (toasts, loading states)
  5. Test back button behavior inside modals

Tooling

ToolPlatformWhat It Catches
Accessibility InspectoriOS Simulator (Xcode)Missing labels, contrast issues, element hierarchy
Android Accessibility ScannerAndroid device/emulatorTouch target size, contrast, missing labels
eslint-plugin-react-native-a11yCI/EditorMissing labels, roles, states at lint time
DetoxBothEnd-to-end flows with accessibility assertions
axe DevTools MobileBothAutomated 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.

CategoryCheckWCAG SC
LabelsEvery interactive element has an accessibilityLabel1.1.1, 4.1.2
LabelsLabels are descriptive, not generic ("Submit order" not "Button")2.4.6
LabelsImages have descriptive labels or are marked decorative1.1.1
RolesCorrect accessibilityRole on all interactive elements4.1.2
StateaccessibilityState reflects current state (checked, disabled, expanded)4.1.2
FocusFocus moves to new content after navigation or modal open2.4.3
FocusFocus returns to trigger element when modal/dialog closes2.4.3
FocusNo keyboard/focus traps (except intentional modal traps)2.1.2
Reading OrderScreen reader reading order matches visual layout1.3.2
Touch TargetsAll interactive elements are at least 48x48 dp2.5.8
Text ScalingUI is usable at 200% text scale1.4.4
Text ScalingallowFontScaling is not set to false on any Text1.4.4
ContrastText contrast ratio is at least 4.5:1 (3:1 for large text)1.4.3
ColorInformation is not conveyed by color alone1.4.1
MotionAnimations respect isReduceMotionEnabled2.3.3
Live RegionsDynamic content changes are announced to screen readers4.1.3
ErrorsForm errors are announced and focusable3.3.1
PlatformTested with VoiceOver (iOS) AND TalkBack (Android)-
Dark ModeContrast ratios hold in both light and dark mode1.4.3
Invert ColorsImages use accessibilityIgnoresInvertColors where needed1.4.1

On this page