+1 (415) 943-4271

Audit-Ready Accessibility in React Native: Screen Readers, Dynamic Type, and Automated Checks

Accessibility used to show up in our client projects as a line item somebody deleted during scope negotiation. That changed. The European Accessibility Act's compliance deadline landed in June 2025, US procurement contracts routinely reference WCAG 2.2 AA, and enterprise buyers now send accessibility conformance questionnaires before they will sign. If you sell your app to a business, someone is going to ask.

The good news for React Native teams: the framework maps cleanly onto both platform accessibility APIs. Most of the work is knowing which props exist and where the abstraction leaks. This tutorial is the checklist we actually run on client codebases.

Turn the screen reader on first

Before reading any further, do this. It takes ten minutes and it will change what you think your app's problems are.

  • iOS: Settings → Accessibility → VoiceOver, and set the Accessibility Shortcut (triple-click side button) so you can toggle it fast.
  • Android: Settings → Accessibility → TalkBack. Swipe right to move focus, double-tap to activate.

Now complete your app's primary flow — sign in, main task, checkout — without looking at the screen. Write down every place you got stuck. That list, not a lint rule, is your backlog.

The four props that do most of the work

React Native exposes a unified accessibility API that compiles down to UIAccessibility on iOS and the AccessibilityNodeInfo tree on Android.

<Pressable
  accessible
  accessibilityRole="button"
  accessibilityLabel="Add to cart"
  accessibilityHint="Adds this item to your shopping cart"
  accessibilityState={{ disabled: isSaving, busy: isSaving }}
  onPress={addToCart}
>
  <Icon name="cart-plus" />
</Pressable>
  • accessible collapses the subtree into a single focusable element. Use it on composite controls, not on containers that hold several independent controls.
  • accessibilityRole tells the screen reader how to announce and what gestures to expose. button, link, header, image, search, switch, checkbox, radio, adjustable, alert, summary, tab, tablist are the ones we use most.
  • accessibilityLabel is what gets read. Keep it short, describe the action, and never include the word "button" — the role already says that.
  • accessibilityHint explains a non-obvious result. Optional, and users can turn it off, so never put required information there.

accessibilityState is the one teams forget. A toggle that never announces checked/unchecked, or a disabled button that still announces as tappable, is a broken control even if the label is perfect.

Icon-only buttons and the label audit

The single most common defect we find: icon buttons with no label. TalkBack announces them as "button" or, worse, reads the internal image filename.

Run this search on your codebase and expect to be depressed:

grep -rn "<Pressable\|<TouchableOpacity" src \
  | grep -v accessibilityLabel

The fix is boring but it is the highest-value hour you will spend. If your design system has an IconButton, make the label a required prop and the problem stops recurring:

type IconButtonProps = {
  icon: IconName;
  label: string; // required on purpose
  onPress: () => void;
};

export function IconButton({ icon, label, onPress }: IconButtonProps) {
  return (
    <Pressable
      accessible
      accessibilityRole="button"
      accessibilityLabel={label}
      hitSlop={8}
      onPress={onPress}
    >
      <Icon name={icon} />
    </Pressable>
  );
}

While you are there: check hit targets. Both platforms ask for roughly 44x44pt (iOS) / 48x48dp (Android). hitSlop expands the touch target without changing layout.

Custom components leak

Anything you built out of View and PanResponder — a segmented control, a star rating, a custom slider, a bottom sheet — has no built-in semantics. You have to supply them.

For value-based controls, use the adjustable role plus accessibilityValue, and implement the increment/decrement actions so screen reader users can change the value with a swipe:

<View
  accessible
  accessibilityRole="adjustable"
  accessibilityLabel="Delivery radius"
  accessibilityValue={{ min: 1, max: 50, now: radius, text: `${radius} kilometres` }}
  accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
  onAccessibilityAction={({ nativeEvent }) => {
    if (nativeEvent.actionName === 'increment') setRadius(r => Math.min(50, r + 1));
    if (nativeEvent.actionName === 'decrement') setRadius(r => Math.max(1, r - 1));
  }}
>
  <SliderTrack value={radius} />
</View>

Modals, sheets and focus traps

When a modal or bottom sheet opens, focus must move into it and must not escape behind it. React Native's own Modal handles most of this on iOS; custom sheets rendered into a sibling view almost never do. Two tools:

import { AccessibilityInfo, findNodeHandle } from 'react-native';

// Move focus into the sheet on open
useEffect(() => {
  if (!visible) return;
  const tag = findNodeHandle(titleRef.current);
  if (tag) AccessibilityInfo.setAccessibilityFocus(tag);
}, [visible]);

And hide the background from the accessibility tree while the sheet is open:

<View
  importantForAccessibility={sheetOpen ? 'no-hide-descendants' : 'auto'} // Android
  accessibilityElementsHidden={sheetOpen}                                 // iOS
>
  {screenContent}
</View>

Skipping this is why so many bottom-sheet apps let a TalkBack user wander onto invisible buttons underneath the overlay.

Announcing things that change without a tap

Toasts, validation errors, async results and progress updates are silent to a screen reader unless you say otherwise.

// Polite, one-off announcement
AccessibilityInfo.announceForAccessibility('3 results found');

// Or mark a region as live
<Text accessibilityLiveRegion="polite">{statusMessage}</Text>

accessibilityLiveRegion is Android-only; on iOS use announceForAccessibility (or announceForAccessibilityWithOptions when you need the queued/interrupt behaviour). We usually wrap both in a tiny announce() helper so call sites do not branch on platform.

For forms, pair the announcement with accessibilityInvalid-style state: put the error text next to the field, reference it in the field's accessibilityLabel or use accessibilityLabelledBy on Android, and move focus to the first invalid field on submit.

Text that scales, colours that pass, motion you can turn off

Dynamic type. Users routinely run 150–200% font scale. Hard-coded height on anything containing text will clip it. Rules we apply:

  • Never set a fixed height on a button, row or chip that wraps text — use minHeight plus vertical padding.
  • Use allowFontScaling (default true) and only disable it for genuinely fixed-size numerals; never for body copy.
  • Cap absurd scales at component level with maxFontSizeMultiplier rather than turning scaling off entirely.
  • Read the current scale with useWindowDimensions().fontScale when you need to swap a horizontal layout for a vertical one.

Test at the largest accessibility text size on both platforms. It finds more layout bugs than any device-matrix sweep.

Contrast. WCAG 2.2 AA wants 4.5:1 for body text, 3:1 for large text and for UI component boundaries. Put the check in your design tokens, not in code review. Grey-on-grey placeholder text and low-contrast disabled states are the usual failures.

Motion. Parallax headers and big spring transitions can trigger vestibular symptoms. Respect the system setting:

const reduceMotion = useReducedMotion(); // react-native-reanimated
const duration = reduceMotion ? 0 : 300;

If you are not on Reanimated, AccessibilityInfo.isReduceMotionEnabled() plus the reduceMotionChanged listener gives you the same signal.

Lists, headings and reading order

Screen reader users navigate by structure, not by scrolling. Two cheap wins:

  1. Mark every section title with accessibilityRole="header". Both TalkBack and VoiceOver offer heading-by-heading navigation, and it turns a 40-swipe screen into a 4-swipe screen.
  2. On list rows, set accessible on the row so it reads as one item — "Invoice 4821, overdue, £320" — instead of three separate focus stops. Then expose row actions through accessibilityActions rather than hidden swipe gestures.

Also check reading order. Absolute positioning and flexbox row-reverse can produce a visual order that does not match the tree order; on iOS you can correct it with accessibilityViewIsModal and container grouping, but the better fix is usually to change the layout.

Automate what you can, in CI

Manual passes catch the real issues, but automation stops regressions.

Lint. eslint-plugin-react-native-a11y catches missing labels, roles on touchables, and nonsense role/state combinations. Start with it in warn mode, fix, then flip to error so nothing new slips through.

Unit tests. React Native Testing Library queries the accessibility tree by design, which means writing accessible tests and accessible apps are the same activity:

import { render, screen, fireEvent } from '@testing-library/react-native';

test('cart button is labelled and reflects busy state', () => {
  render(<AddToCart saving />);
  const button = screen.getByRole('button', { name: 'Add to cart' });
  expect(button).toBeDisabled();
});

If getByRole cannot find your control, neither can a screen reader. That is the whole point.

End-to-end. Maestro selectors resolve against accessibility identifiers, so an E2E suite written with tapOn: "Add to cart" doubles as a label smoke test. Add a job that fails the build if a critical-path selector disappears.

Platform tooling. Run Android's Accessibility Scanner on a debug build and Xcode's Accessibility Inspector audit on the simulator before each release. Both take minutes and both find contrast and touch-target issues that code-level tools cannot see.

A release checklist you can paste into your tracker

  • Primary flow completed end-to-end with TalkBack and with VoiceOver
  • No interactive element without an accessibilityLabel
  • Roles set; accessibilityState reflects disabled / checked / selected / busy
  • Modals and sheets trap focus and hide background content from the a11y tree
  • Errors, toasts and async results are announced
  • Layout survives the maximum system font scale on both platforms
  • Text and UI boundaries meet WCAG 2.2 AA contrast
  • Reduce Motion honoured for non-essential animation
  • Headings marked; list rows group into single focus stops
  • a11y lint rules at error; RNTL role-based queries in the suite; scanner/inspector audits clean

What this costs

On a mid-sized app, a first pass is typically one to two developer weeks: a day of manual auditing, a few days of label and role work, a few days on dynamic type layout fallout, then lint and test wiring. Retrofitting it under a procurement deadline costs several times that, because the layout fixes turn into design decisions and design decisions turn into meetings.

Build it into the component library once and the marginal cost per screen goes to roughly zero.

If you need an accessibility audit of an existing React Native app, or a design-system pass that makes accessible defaults the easy path for your team, get in touch — it is one of the more common engagements we run alongside New Architecture and Expo migration work.