React Native Accessibility: Building Inclusive Apps with Expo

Build accessible React Native apps from day one. Learn core accessibility props, VoiceOver and TalkBack testing, dynamic announcements, focus management, reduced motion support, and automated testing with ESLint, Jest, and AMA.

Accessibility isn't optional anymore in mobile development. Over 1 billion people worldwide live with some form of disability — that's roughly 15% of the global population — and the apps they rely on every day need to work just as well for them as for everyone else. For React Native developers, this is both a moral imperative and an increasingly legal one. ADA-related lawsuits targeting mobile applications have climbed sharply year over year, and courts have consistently ruled that mobile apps fall within the scope of Title III. I've seen teams scramble to retrofit accessibility after receiving a legal demand letter, and trust me — building an accessible React Native app from day one is far less painful (and far less expensive) than doing it under pressure later.

Here's what makes React Native interesting in the accessibility landscape. Unlike web frameworks that produce HTML which assistive technologies must interpret through a browser layer, React Native renders genuine native UI components. On iOS, a TouchableOpacity becomes a UIView. On Android, it becomes an android.view.View. This means React Native accessibility maps directly to the platform's own accessibility APIs — the very same APIs that UIKit and Android SDK developers use. When you set accessibilityRole="button", you're telling UIAccessibility and Android's AccessibilityNodeInfo exactly what that element is. VoiceOver and TalkBack then announce it correctly, with no translation layer in between.

This guide covers everything you need to build fully inclusive React Native and Expo accessibility-compliant apps in 2026. You'll learn the core accessibility props that form the foundation of every accessible component, how to manage dynamic announcements and focus, how to respect user preferences like reduced motion, practical patterns for forms, modals, and lists, and how to test your work with manual tools, Jest, ESLint, and runtime libraries. Whether you're starting a fresh Expo project or auditing an existing one, this guide gives you actionable, tested knowledge you can apply right away.

How Accessibility Works in React Native

To write effective React Native a11y code, you need a clear mental model of what's actually happening at runtime. React Native doesn't expose a DOM. There are no aria- attributes, no HTML landmark elements, and no browser accessibility tree. Instead, when your JavaScript renders a component tree, React Native's bridge (or in the new architecture, JSI) translates that tree into native UI views on each platform. The operating system then constructs an Accessibility Tree from those views.

This Accessibility Tree is what screen readers traverse. VoiceOver on iOS and TalkBack on Android walk the tree node by node, reading out information about each element.

Here's the critical insight: without explicit accessibility props, the OS only knows what an element looks like, not what it means. A red rectangle with white text that says "Submit" looks like a button to a sighted user. But to the OS accessibility layer, it's just a View containing a Text element — with no semantic meaning attached. The screen reader will announce "Submit" and nothing else. It won't say "button". It won't tell the user they can double-tap to activate it. Honestly, this catches a lot of developers off guard the first time they turn on VoiceOver and swipe through their "finished" app.

When you add props like accessibilityRole, accessibilityLabel, and accessibilityState, you populate the nodes in that accessibility tree with semantic information. VoiceOver then says "Submit, button" and the user knows they can activate it. TalkBack says "Submit, double-tap to activate." This is the entire contract: your props feed the accessibility tree, and the screen reader turns that tree into a meaningful audio experience. Every component that renders as a bare View — which is most of them — has no meaning by default and requires your explicit input.

Core Accessibility Props — The Building Blocks

React Native provides a focused set of accessibility props that cover virtually every scenario you'll encounter. Mastering these props is the single highest-leverage skill in React Native accessibility development. So, let's dive into each one.

accessibilityLabel

The accessibilityLabel prop is the first thing a React Native screen reader announces when a user focuses an element. For any element that lacks meaningful visible text — icon buttons being the most common culprit — this prop isn't optional; it's mandatory. Without it, VoiceOver might announce "image" or nothing at all, and TalkBack may announce the resource name of the icon asset (which is never what you want).

Two important rules worth committing to memory. First, avoid writing labels in ALL CAPS, because VoiceOver interprets all-caps strings as abbreviations and spells them out letter by letter — "SAVE" becomes "S-A-V-E". I learned this one the hard way on a production app. Second, don't duplicate visible text in the label. If your button already displays the word "Save", React Native will concatenate the visible text and the label, causing the screen reader to announce "Save Save." Only use accessibilityLabel when the visible content is insufficient or absent.

import React from 'react';
import { TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';

interface IconButtonProps {
  onPress: () => void;
  isFavorited: boolean;
}

export const FavoriteButton: React.FC<IconButtonProps> = ({ onPress, isFavorited }) => {
  return (
    <TouchableOpacity
      onPress={onPress}
      accessibilityLabel={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
      accessibilityRole="button"
      style={styles.button}
    >
      <Ionicons
        name={isFavorited ? 'heart' : 'heart-outline'}
        size={24}
        color={isFavorited ? '#e74c3c' : '#888'}
      />
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: {
    padding: 12,
  },
});

accessibilityRole

The accessibilityRole prop communicates the purpose of an element to the accessibility system. Roles are how screen readers know to say "button" after reading a label, or to announce that a heading exists for navigation. Without a role, custom touchable elements get announced as generic interactive elements with no affordance cues — which is confusing and unhelpful.

Common roles include "button", "link", "header", "image", "adjustable", "progressbar", "checkbox", "radio", "switch", "tab", "search", and "none". Any custom component built from TouchableOpacity, Pressable, or TouchableHighlight that acts as a button must carry accessibilityRole="button". Native Button and many built-in components set this automatically, but custom implementations don't — and that's where things tend to fall through the cracks.

import React from 'react';
import { Pressable, Text, View, StyleSheet } from 'react-native';

interface CustomButtonProps {
  label: string;
  onPress: () => void;
  disabled?: boolean;
}

export const CustomButton: React.FC<CustomButtonProps> = ({ label, onPress, disabled }) => {
  return (
    <Pressable
      onPress={onPress}
      disabled={disabled}
      accessibilityRole="button"
      accessibilityState={{ disabled: disabled ?? false }}
      style={({ pressed }) => [styles.button, pressed && styles.pressed, disabled && styles.disabled]}
    >
      <Text style={styles.label}>{label}</Text>
    </Pressable>
  );
};

interface UploadProgressProps {
  progress: number;
}

export const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
  return (
    <View
      accessibilityRole="progressbar"
      accessibilityValue={{ min: 0, max: 100, now: progress, text: `${progress}% uploaded` }}
      style={styles.progressTrack}
    >
      <View style={[styles.progressFill, { width: `${progress}%` }]} />
    </View>
  );
};

const styles = StyleSheet.create({
  button: { backgroundColor: '#007AFF', padding: 14, borderRadius: 8, alignItems: 'center' },
  pressed: { opacity: 0.7 },
  disabled: { backgroundColor: '#ccc' },
  label: { color: '#fff', fontSize: 16, fontWeight: '600' },
  progressTrack: { height: 8, backgroundColor: '#e0e0e0', borderRadius: 4 },
  progressFill: { height: '100%', backgroundColor: '#007AFF', borderRadius: 4 },
});

accessibilityState

The accessibilityState prop conveys the current state of an interactive element. It accepts an object with boolean properties: disabled, selected, checked, busy, and expanded. These values get translated into audio cues — VoiceOver announces "dimmed" for disabled, "checked" or "unchecked" for checkboxes, and "selected" for tab items.

The golden rule here: always keep accessibilityState in sync with your visual state. If your toggle looks on but reports off to the accessibility tree, you've created a deeply confusing experience for screen reader users.

import React, { useState } from 'react';
import { Pressable, Text, View, StyleSheet } from 'react-native';

export const NotificationsToggle: React.FC = () => {
  const [isEnabled, setIsEnabled] = useState(false);

  return (
    <View style={styles.row}>
      <Text style={styles.rowLabel}>Push Notifications</Text>
      <Pressable
        onPress={() => setIsEnabled(prev => !prev)}
        accessibilityRole="switch"
        accessibilityLabel="Push notifications"
        accessibilityState={{ checked: isEnabled }}
        style={[styles.toggle, isEnabled && styles.toggleActive]}
      >
        <View style={[styles.thumb, isEnabled && styles.thumbActive]} />
      </Pressable>
    </View>
  );
};

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12 },
  rowLabel: { fontSize: 16, color: '#1a1a1a' },
  toggle: { width: 52, height: 30, borderRadius: 15, backgroundColor: '#ccc', justifyContent: 'center', paddingHorizontal: 2 },
  toggleActive: { backgroundColor: '#34C759' },
  thumb: { width: 26, height: 26, borderRadius: 13, backgroundColor: '#fff', alignSelf: 'flex-start' },
  thumbActive: { alignSelf: 'flex-end' },
});

accessibilityHint

The accessibilityHint prop explains what will happen when the user activates an element. It describes the outcome, not the action. This is an important distinction — you're telling the user about the result, not instructing them on how to interact.

Users can configure their screen readers to suppress hints when they're experienced enough to not need them, so hints should be informative but never essential for understanding what an element is. Use this prop only when the label alone leaves ambiguity about the result of an interaction.

import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';

interface ExportButtonProps {
  onPress: () => void;
}

export const ExportButton: React.FC<ExportButtonProps> = ({ onPress }) => {
  return (
    <TouchableOpacity
      onPress={onPress}
      accessibilityRole="button"
      accessibilityLabel="Export report"
      accessibilityHint="Saves a PDF to your device's Downloads folder"
      style={styles.button}
    >
      <Text style={styles.label}>Export Report</Text>
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: { backgroundColor: '#5856D6', padding: 14, borderRadius: 8, alignItems: 'center' },
  label: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

accessibilityValue

For elements with a range of values — sliders, steppers, progress bars, and similar controls — accessibilityValue provides the quantitative context that a screen reader needs to give meaningful feedback. The prop accepts min, max, now, and text. When text is provided, it overrides the numeric announcement with a custom string, which is really useful when a number alone doesn't tell the whole story (e.g., "3 out of 5 stars" instead of just "3").

import React, { useState } from 'react';
import { View, Text, Slider, StyleSheet } from 'react-native';

export const VolumeControl: React.FC = () => {
  const [volume, setVolume] = useState(50);

  return (
    <View style={styles.container}>
      <Text style={styles.label}>Volume: {volume}%</Text>
      <Slider
        minimumValue={0}
        maximumValue={100}
        step={1}
        value={volume}
        onValueChange={(val) => setVolume(Math.round(val))}
        accessibilityRole="adjustable"
        accessibilityLabel="Volume control"
        accessibilityValue={{
          min: 0,
          max: 100,
          now: volume,
          text: `${volume} percent`,
        }}
        style={styles.slider}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: { paddingHorizontal: 16, paddingVertical: 12 },
  label: { fontSize: 16, marginBottom: 8 },
  slider: { width: '100%', height: 40 },
});

accessible (Grouping)

The accessible prop set to true on a container View tells the accessibility system to treat the container and all its children as a single focusable unit. The screen reader focuses the group as one item and reads all child content together. This is incredibly powerful for card components where separately focusable children would create a confusing, fragmented navigation experience.

The key rule: group by meaning, not by layout. A card that represents a single concept should be one unit. A container that holds multiple unrelated controls shouldn't be grouped. In my experience, getting this distinction right makes the biggest difference in how natural your app feels to a screen reader user.

import React from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';

interface ArticleCardProps {
  title: string;
  author: string;
  readTime: string;
  thumbnailUri: string;
}

export const ArticleCard: React.FC<ArticleCardProps> = ({ title, author, readTime, thumbnailUri }) => {
  const groupLabel = `${title}, by ${author}, ${readTime} read`;

  return (
    <View
      accessible={true}
      accessibilityRole="button"
      accessibilityLabel={groupLabel}
      accessibilityHint="Opens the full article"
      style={styles.card}
    >
      <Image source={{ uri: thumbnailUri }} style={styles.thumbnail} accessibilityElementsHidden={true} />
      <View style={styles.content}>
        <Text style={styles.title}>{title}</Text>
        <Text style={styles.meta}>{author} · {readTime} read</Text>
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  card: { flexDirection: 'row', padding: 12, backgroundColor: '#fff', borderRadius: 10, marginVertical: 6 },
  thumbnail: { width: 72, height: 72, borderRadius: 8 },
  content: { flex: 1, marginLeft: 12, justifyContent: 'center' },
  title: { fontSize: 15, fontWeight: '600', color: '#1a1a1a' },
  meta: { fontSize: 13, color: '#888', marginTop: 4 },
});

Dynamic Accessibility: Announcements and Focus Management

Static props handle elements that are always on screen. But real-world apps are dynamic — a form submits and shows a success banner, a modal appears, a list item loads new data. For these scenarios, React Native provides AccessibilityInfo, a module that lets you programmatically announce content and direct focus to specific elements. This is where things get really interesting (and where a lot of apps drop the ball).

Announcing Dynamic Content

AccessibilityInfo.announceForAccessibility() sends a string directly to the active screen reader without requiring the user to navigate to a specific element. This is the right tool for transient feedback like toast messages, form validation results, or background task completions.

import React, { useState } from 'react';
import { View, TextInput, Pressable, Text, AccessibilityInfo, StyleSheet } from 'react-native';

export const ContactForm: React.FC = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async () => {
    if (!name || !email) {
      AccessibilityInfo.announceForAccessibility('Please fill in all required fields before submitting.');
      return;
    }

    setIsSubmitting(true);
    AccessibilityInfo.announceForAccessibility('Submitting your message, please wait.');

    try {
      await submitContactForm({ name, email });
      AccessibilityInfo.announceForAccessibility('Your message was sent successfully. We will reply within 24 hours.');
    } catch {
      AccessibilityInfo.announceForAccessibility('Submission failed. Please check your connection and try again.');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <View style={styles.form}>
      <TextInput
        value={name}
        onChangeText={setName}
        accessibilityLabel="Full name"
        placeholder="Full name"
        style={styles.input}
      />
      <TextInput
        value={email}
        onChangeText={setEmail}
        accessibilityLabel="Email address"
        keyboardType="email-address"
        autoCapitalize="none"
        placeholder="Email address"
        style={styles.input}
      />
      <Pressable
        onPress={handleSubmit}
        accessibilityRole="button"
        accessibilityLabel="Send message"
        accessibilityState={{ busy: isSubmitting, disabled: isSubmitting }}
        style={styles.button}
      >
        <Text style={styles.buttonLabel}>{isSubmitting ? 'Sending...' : 'Send Message'}</Text>
      </Pressable>
    </View>
  );
};

async function submitContactForm(_data: { name: string; email: string }): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, 1500));
}

const styles = StyleSheet.create({
  form: { padding: 16, gap: 12 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
  button: { backgroundColor: '#007AFF', padding: 14, borderRadius: 8, alignItems: 'center' },
  buttonLabel: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

On Android, the accessibilityLiveRegion prop offers a declarative alternative. Setting it to "polite" queues an announcement after the current speech finishes. "assertive" interrupts immediately. "none" silences announcements for that subtree. On iOS though, accessibilityLiveRegion has no effect — you'll need to use announceForAccessibility with a brief setTimeout delay (typically 500-1000ms) to make sure the announcement fires after any navigation-related speech completes. It's a small platform difference, but it trips people up.

Focus Management

When a modal or overlay appears, focus must be programmatically moved into it. If focus remains behind the modal, a screen reader user will be completely stranded in invisible content. That's a terrible experience. Use AccessibilityInfo.setAccessibilityFocus() with a native ref to redirect focus at the right moment.

import React, { useRef, useEffect } from 'react';
import { Modal, View, Text, Pressable, findNodeHandle, AccessibilityInfo, StyleSheet } from 'react-native';

interface ConfirmModalProps {
  visible: boolean;
  title: string;
  message: string;
  onConfirm: () => void;
  onDismiss: () => void;
}

export const ConfirmModal: React.FC<ConfirmModalProps> = ({ visible, title, message, onConfirm, onDismiss }) => {
  const titleRef = useRef<Text>(null);

  useEffect(() => {
    if (visible && titleRef.current) {
      const timeout = setTimeout(() => {
        const node = findNodeHandle(titleRef.current);
        if (node) {
          AccessibilityInfo.setAccessibilityFocus(node);
        }
      }, 300);
      return () => clearTimeout(timeout);
    }
  }, [visible]);

  return (
    <Modal
      visible={visible}
      transparent
      animationType="fade"
      onRequestClose={onDismiss}
      accessibilityViewIsModal={true}
    >
      <View style={styles.overlay}>
        <View style={styles.dialog}>
          <Text ref={titleRef} style={styles.title} accessibilityRole="header">
            {title}
          </Text>
          <Text style={styles.message}>{message}</Text>
          <View style={styles.actions}>
            <Pressable
              onPress={onDismiss}
              accessibilityRole="button"
              accessibilityLabel="Cancel"
              style={[styles.actionButton, styles.cancelButton]}
            >
              <Text style={styles.cancelLabel}>Cancel</Text>
            </Pressable>
            <Pressable
              onPress={onConfirm}
              accessibilityRole="button"
              accessibilityLabel="Confirm"
              style={[styles.actionButton, styles.confirmButton]}
            >
              <Text style={styles.confirmLabel}>Confirm</Text>
            </Pressable>
          </View>
        </View>
      </View>
    </Modal>
  );
};

const styles = StyleSheet.create({
  overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' },
  dialog: { backgroundColor: '#fff', borderRadius: 14, padding: 24, width: '80%', gap: 12 },
  title: { fontSize: 18, fontWeight: '700', color: '#1a1a1a' },
  message: { fontSize: 15, color: '#555', lineHeight: 22 },
  actions: { flexDirection: 'row', gap: 12, marginTop: 8 },
  actionButton: { flex: 1, padding: 12, borderRadius: 8, alignItems: 'center' },
  cancelButton: { backgroundColor: '#f0f0f0' },
  confirmButton: { backgroundColor: '#007AFF' },
  cancelLabel: { fontSize: 16, fontWeight: '600', color: '#1a1a1a' },
  confirmLabel: { fontSize: 16, fontWeight: '600', color: '#fff' },
});

Note the use of accessibilityViewIsModal={true} on the Modal component. On iOS, this prop prevents VoiceOver from reading elements outside the modal while it's open. On Android, the system handles this automatically when using the built-in Modal component — one of those nice platform freebies.

Handling User Preferences: Reduced Motion and Color Scheme

Respecting system-level accessibility preferences is a dimension of inclusion that goes well beyond screen readers. Users with vestibular disorders, epilepsy, or motion sensitivity rely on the "Reduce Motion" setting to avoid discomfort or harm. I've shipped apps where we initially ignored this preference for the sake of "visual polish," and we heard from users. Ignoring it is a genuine accessibility failure.

import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';

export function useReducedMotion(): boolean {
  const [reduceMotion, setReduceMotion] = useState(false);

  useEffect(() => {
    AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);

    const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);
    return () => subscription.remove();
  }, []);

  return reduceMotion;
}
import React, { useRef } from 'react';
import { Pressable, Animated, Text, StyleSheet } from 'react-native';
import { useReducedMotion } from '../hooks/useReducedMotion';

export const AnimatedCard: React.FC<{ onPress: () => void; label: string }> = ({ onPress, label }) => {
  const scale = useRef(new Animated.Value(1)).current;
  const reduceMotion = useReducedMotion();

  const handlePressIn = () => {
    if (reduceMotion) return;
    Animated.spring(scale, { toValue: 0.96, useNativeDriver: true }).start();
  };

  const handlePressOut = () => {
    if (reduceMotion) return;
    Animated.spring(scale, { toValue: 1, useNativeDriver: true }).start();
  };

  return (
    <Animated.View style={{ transform: [{ scale }] }}>
      <Pressable
        onPress={onPress}
        onPressIn={handlePressIn}
        onPressOut={handlePressOut}
        accessibilityRole="button"
        accessibilityLabel={label}
        style={styles.card}
      >
        <Text style={styles.label}>{label}</Text>
      </Pressable>
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  card: { backgroundColor: '#fff', padding: 16, borderRadius: 12, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 8, elevation: 3 },
  label: { fontSize: 16, fontWeight: '600', color: '#1a1a1a' },
});

Color contrast is another critical area. WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18pt or 14pt bold). Use tools like the WebAIM Contrast Checker or Stark (available as a Figma plugin) during design, and verify in code with libraries like polished or color. Touch targets are a related concern that's easy to overlook: Apple's Human Interface Guidelines and Android's Material Design both specify a minimum tappable area of 44x44 points. The hitSlop prop is your friend here — it extends touch areas without changing the visual layout.

import React from 'react';
import { Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';

export const CloseButton: React.FC<{ onPress: () => void }> = ({ onPress }) => {
  return (
    <Pressable
      onPress={onPress}
      accessibilityRole="button"
      accessibilityLabel="Close"
      hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
      style={styles.button}
    >
      <Ionicons name="close" size={20} color="#333" />
    </Pressable>
  );
};

const styles = StyleSheet.create({
  button: {
    width: 44,
    height: 44,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

Building Accessible Components: Practical Patterns

Accessible Form with Validation

Forms are among the most accessibility-sensitive parts of any app. Every input needs a label. Error messages must be announced dynamically when they appear — not just displayed visually. Required fields must be indicated. This is one area where I see teams cut corners most often, and it's exactly the area where screen reader users struggle the most. The following pattern demonstrates a robust, accessible form with error announcement.

import React, { useState, useRef } from 'react';
import { View, Text, TextInput, Pressable, AccessibilityInfo, StyleSheet, findNodeHandle } from 'react-native';

interface FormErrors {
  username?: string;
  password?: string;
}

export const LoginForm: React.FC = () => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<FormErrors>({});
  const usernameRef = useRef<TextInput>(null);

  const validate = (): boolean => {
    const newErrors: FormErrors = {};
    if (!username.trim()) newErrors.username = 'Username is required.';
    if (password.length < 8) newErrors.password = 'Password must be at least 8 characters.';
    setErrors(newErrors);

    const errorMessages = Object.values(newErrors);
    if (errorMessages.length > 0) {
      AccessibilityInfo.announceForAccessibility(
        `Form has ${errorMessages.length} error${errorMessages.length > 1 ? 's' : ''}. ${errorMessages.join(' ')}`
      );
      const node = findNodeHandle(usernameRef.current);
      if (node) setTimeout(() => AccessibilityInfo.setAccessibilityFocus(node), 600);
      return false;
    }
    return true;
  };

  const handleLogin = () => {
    if (!validate()) return;
    AccessibilityInfo.announceForAccessibility('Logging in, please wait.');
  };

  return (
    <View style={styles.form}>
      <View style={styles.field}>
        <Text style={styles.fieldLabel} nativeID="usernameLabel">Username</Text>
        <TextInput
          ref={usernameRef}
          value={username}
          onChangeText={setUsername}
          accessibilityLabel="Username"
          autoCapitalize="none"
          autoCorrect={false}
          style={[styles.input, errors.username && styles.inputError]}
        />
        {errors.username ? (
          <Text style={styles.errorText} accessibilityRole="alert">{errors.username}</Text>
        ) : null}
      </View>

      <View style={styles.field}>
        <Text style={styles.fieldLabel}>Password</Text>
        <TextInput
          value={password}
          onChangeText={setPassword}
          accessibilityLabel="Password"
          secureTextEntry
          style={[styles.input, errors.password && styles.inputError]}
        />
        {errors.password ? (
          <Text style={styles.errorText} accessibilityRole="alert">{errors.password}</Text>
        ) : null}
      </View>

      <Pressable
        onPress={handleLogin}
        accessibilityRole="button"
        accessibilityLabel="Log in"
        style={styles.submitButton}
      >
        <Text style={styles.submitLabel}>Log In</Text>
      </Pressable>
    </View>
  );
};

const styles = StyleSheet.create({
  form: { padding: 16, gap: 16 },
  field: { gap: 4 },
  fieldLabel: { fontSize: 14, fontWeight: '600', color: '#333' },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
  inputError: { borderColor: '#e74c3c' },
  errorText: { fontSize: 13, color: '#e74c3c', marginTop: 2 },
  submitButton: { backgroundColor: '#007AFF', padding: 14, borderRadius: 8, alignItems: 'center', marginTop: 8 },
  submitLabel: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

Accessible List Item

List items in e-commerce apps, news feeds, and catalogs often contain a mix of images, text, prices, and action buttons. Grouping these into a single accessible element with a composed label creates a far better screen reader experience than letting each child element receive separate focus. Without grouping, a user has to swipe through four or five elements just to understand one product — it's tedious and disorienting.

import React from 'react';
import { View, Text, Image, Pressable, StyleSheet } from 'react-native';

interface ProductCardProps {
  name: string;
  price: string;
  rating: number;
  imageUri: string;
  onPress: () => void;
}

export const ProductCard: React.FC<ProductCardProps> = ({ name, price, rating, imageUri, onPress }) => {
  const accessibilityLabel = `${name}, priced at ${price}, rated ${rating} out of 5 stars`;

  return (
    <Pressable
      onPress={onPress}
      accessible={true}
      accessibilityRole="button"
      accessibilityLabel={accessibilityLabel}
      accessibilityHint="Opens product details"
      style={styles.card}
    >
      <Image
        source={{ uri: imageUri }}
        style={styles.image}
        accessibilityElementsHidden={true}
        importantForAccessibility="no"
      />
      <View style={styles.details}>
        <Text style={styles.name}>{name}</Text>
        <Text style={styles.price}>{price}</Text>
        <Text style={styles.rating}>{'★'.repeat(rating)}{'☆'.repeat(5 - rating)}</Text>
      </View>
    </Pressable>
  );
};

const styles = StyleSheet.create({
  card: { flexDirection: 'row', backgroundColor: '#fff', borderRadius: 10, padding: 12, marginVertical: 6, elevation: 2, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 6 },
  image: { width: 80, height: 80, borderRadius: 8 },
  details: { flex: 1, marginLeft: 12, justifyContent: 'center', gap: 4 },
  name: { fontSize: 16, fontWeight: '600', color: '#1a1a1a' },
  price: { fontSize: 15, fontWeight: '700', color: '#007AFF' },
  rating: { fontSize: 14, color: '#f5a623' },
});

Accessible Modal

Modals must trap focus while they're visible, announce their appearance, and restore focus when dismissed. The ConfirmModal example shown earlier in this guide demonstrates the key patterns: accessibilityViewIsModal={true} to trap VoiceOver focus, a useEffect hook to redirect focus to the modal title on open, and onRequestClose to handle the Android back button. For more complex modals with scrollable content or multiple sections, make sure the first focusable element is either the title or a close button, and that the last focusable element wraps back to the first within the modal.

Testing Accessibility

Writing accessible code is only half the battle. You need a systematic testing strategy that combines manual verification with automated checks to catch regressions and confirm real-world behavior. I can't stress this enough — accessibility bugs are the kind that slip through code review because everything looks fine visually.

Manual Testing

For React Native VoiceOver TalkBack testing, manual testing on real devices and configured emulators is irreplaceable. No automated tool can fully replicate the experience of a screen reader user navigating your app.

  • VoiceOver on iOS: Requires a physical device. Go to Settings > Accessibility > VoiceOver and enable it, or triple-click the side button if you've set the Accessibility Shortcut. Swipe right to move forward through elements, swipe left to go back, and double-tap to activate. Focus must move in a logical, predictable order — typically top-left to bottom-right.
  • TalkBack on Android: Works on physical devices and on emulators with the Android Accessibility Suite APK installed. Enable in Settings > Accessibility > TalkBack. Use the same swipe gestures as VoiceOver, plus additional two-finger gestures for scrolling.
  • Xcode Accessibility Inspector: Run from Xcode > Open Developer Tool > Accessibility Inspector. Works with the iOS Simulator and provides a point-and-click inspection of the accessibility tree without needing a screen reader enabled. It's surprisingly useful for quick sanity checks.

Here's a practical manual testing checklist: navigate the entire screen using only swipe gestures; verify every interactive element receives focus; confirm all labels are meaningful and not redundant; check that state changes (enabled/disabled, checked/unchecked) are announced; verify modals trap focus; and confirm dynamic content is announced without requiring manual navigation.

Automated Testing with Jest

@testing-library/react-native provides queries like getByRole, getByLabelText, and getByA11yState that let you write tests verifying accessibility props are correctly applied. These tests run in CI and catch regressions before they reach users — which is exactly the kind of safety net you want.

import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { FavoriteButton } from '../components/FavoriteButton';
import { CustomButton } from '../components/CustomButton';

describe('FavoriteButton accessibility', () => {
  it('announces correct label when not favorited', () => {
    const { getByLabelText } = render(<FavoriteButton onPress={() => {}} isFavorited={false} />);
    expect(getByLabelText('Add to favorites')).toBeTruthy();
  });

  it('announces correct label when favorited', () => {
    const { getByLabelText } = render(<FavoriteButton onPress={() => {}} isFavorited={true} />);
    expect(getByLabelText('Remove from favorites')).toBeTruthy();
  });

  it('has button role', () => {
    const { getByRole } = render(<FavoriteButton onPress={() => {}} isFavorited={false} />);
    expect(getByRole('button')).toBeTruthy();
  });
});

describe('CustomButton accessibility', () => {
  it('marks disabled state correctly', () => {
    const { getByRole } = render(<CustomButton label="Save" onPress={() => {}} disabled={true} />);
    const button = getByRole('button');
    expect(button.props.accessibilityState?.disabled).toBe(true);
  });

  it('calls onPress when not disabled', () => {
    const mockPress = jest.fn();
    const { getByRole } = render(<CustomButton label="Save" onPress={mockPress} />);
    fireEvent.press(getByRole('button'));
    expect(mockPress).toHaveBeenCalledTimes(1);
  });
});

ESLint for Accessibility

eslint-plugin-react-native-a11y adds static analysis rules that catch missing or invalid accessibility props at write-time, before your code even runs. Integrating this into your linting pipeline means every developer on your team writes accessible code by default — not just the ones who remember to think about it.

npm install --save-dev eslint-plugin-react-native-a11y
{
  "extends": [
    "expo",
    "plugin:react-native-a11y/all"
  ],
  "plugins": [
    "react-native-a11y"
  ],
  "rules": {
    "react-native-a11y/has-valid-accessibility-role": "error",
    "react-native-a11y/has-valid-accessibility-state": "error",
    "react-native-a11y/has-accessibility-props": "warn",
    "react-native-a11y/no-nested-touchables": "error",
    "react-native-a11y/touchables-have-accessibility-props": "error"
  }
}

React Native AMA Library

The React Native Accessibility Made Accessible library (@react-native-ama/core) provides runtime accessibility checks during development. It wraps common components and throws developer-friendly warnings when accessibility requirements are violated — for example, when a Pressable is rendered without a label or role. It's fully modular, so you can install only the packages relevant to your component set, and it works seamlessly with both Expo managed and bare workflows.

npx expo install @react-native-ama/core @react-native-ama/pressable
import React from 'react';
import { AMAProvider } from '@react-native-ama/core';
import { RootNavigator } from './navigation/RootNavigator';

export default function App() {
  return (
    <AMAProvider>
      <RootNavigator />
    </AMAProvider>
  );
}

In development builds, AMA will log clear error messages to the console when accessibility violations are detected — things like minimum touch target size failures or missing labels on interactive elements. In production builds, all checks are stripped out with zero runtime overhead, so there's no performance cost to using it.

Accessibility Checklist for React Native Apps

Use this checklist as a pre-release gate and during code review. It's not exhaustive, but it covers the baseline that every app should meet before shipping.

  • All interactive elements have a meaningful accessibilityLabel — especially icon-only buttons.
  • All custom interactive components have an appropriate accessibilityRole assigned.
  • Focus order follows a logical, predictable sequence that matches the visual layout.
  • Dynamic content changes (errors, success messages, loading states) are announced via AccessibilityInfo.announceForAccessibility() or accessibilityLiveRegion.
  • Animations and transitions respect the system Reduce Motion preference.
  • Text and interactive element color contrast meets WCAG 4.5:1 for normal text and 3:1 for large text.
  • All touch targets are at least 44x44 points, using hitSlop where needed.
  • Modals redirect focus on open using AccessibilityInfo.setAccessibilityFocus() and use accessibilityViewIsModal={true}.
  • The app has been manually tested with VoiceOver on iOS and TalkBack on Android.
  • eslint-plugin-react-native-a11y rules are enabled in the project's ESLint configuration.
  • Automated Jest tests use getByRole and getByLabelText to verify accessibility props on key components.
  • Decorative images use accessibilityElementsHidden={true} (iOS) and importantForAccessibility="no" (Android) to hide them from the accessibility tree.

Frequently Asked Questions

How do I test accessibility in React Native without a physical device?

You've got several options, and they're better than you might expect. For Android, the Accessibility Suite APK can be sideloaded onto an Android emulator, giving you a fully functional TalkBack environment without hardware. Download the APK from APKMirror and install it via adb install. For iOS, the Xcode Accessibility Inspector (available from Xcode > Open Developer Tool) works with the iOS Simulator and lets you inspect the accessibility tree, audit for issues, and simulate VoiceOver navigation. One caveat: the Simulator doesn't run VoiceOver audio — for audio testing, you'll still need a physical device. For code-level testing without any device at all, Jest with @testing-library/react-native lets you assert that accessibility props are correctly applied in unit tests, which is fast, repeatable, and CI-compatible.

What is the difference between accessibilityLabel and accessibilityHint?

The accessibilityLabel describes what an element is — its identity. It's the name of the element, announced first whenever a screen reader focuses it. The accessibilityHint describes what will happen when the user activates the element — its outcome. For a button that opens the camera, the label might be "Take photo" and the hint might be "Opens your device camera to capture a new image."

Here's the crucial part: users can disable hints in their screen reader settings (VoiceOver has a "Speak Hints" toggle under Verbosity). This means your app must remain fully usable with hints suppressed. A label must always convey enough meaning on its own. Use hints only to add genuinely useful supplementary context that isn't already obvious from the label or role.

Does React Native support WCAG compliance?

Yes — and the platform's architecture actually makes this more tractable than you might think. Because React Native renders native components, the props you set map directly to the platform's native accessibility APIs — UIAccessibility on iOS and AccessibilityNodeInfoCompat on Android — which are themselves designed to support WCAG criteria. Perceivability is addressed through labels, roles, and sufficient color contrast. Operability is addressed through focus management, touch target sizing, and keyboard/switch access support. Understandability is addressed through meaningful labels, error announcements, and logical focus order. Robustness is inherent in using native accessibility APIs rather than web-based workarounds.

The key requirement is that you must explicitly opt in to accessibility for every custom component, as React Native won't automatically infer semantic meaning from visual layout. It's a bit more work upfront, but the payoff is genuine platform-native accessibility.

How do I handle accessibility for custom components in React Native?

Custom components require explicit opt-in for every accessibility feature. Start with accessible={true} if the component is a container that should be treated as a single unit. Add accessibilityRole to communicate what the component is. Add accessibilityLabel to name it, accessibilityState to reflect its current state, and accessibilityHint if the outcome of interaction isn't obvious from context.

For interactive components built from Pressable or TouchableOpacity, setting accessibilityRole="button" explicitly is always safer and more portable than relying on defaults. Use accessibilityElementsHidden={true} on decorative children within a grouped container to prevent the screen reader from descending into them. And honestly, test every custom component with VoiceOver and TalkBack before shipping — behavior can differ between platforms in ways that aren't always predictable from reading the docs alone.

What tools can I use to automate accessibility testing in React Native?

Several tools complement each other at different layers, and using them together gives you the most comprehensive coverage. eslint-plugin-react-native-a11y catches missing or invalid accessibility props at the source code level, before code runs — integrate it into your editor and CI pipeline. @testing-library/react-native provides accessibility-semantic query APIs for Jest, letting you write unit tests that verify labels, roles, and states are correctly applied. @react-native-ama/core (React Native AMA) performs runtime checks during development builds and logs violations when components are rendered without required accessibility props. axe DevTools Mobile by Deque is a commercial tool that performs dynamic analysis on running apps, detecting issues like insufficient color contrast and missing labels that static analysis simply can't catch.

For the most thorough coverage, use all four in combination: ESLint for authoring, Jest for regression testing in CI, AMA for development-time warnings, and axe DevTools Mobile for periodic full audits. It might sound like a lot, but once you've set them up (which takes maybe an afternoon), they mostly run on autopilot.

About the Author Editorial Team

Our team of expert writers and editors.