React Native Accessibility Guide for Senior Developers

9 min read

Accessibility 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.