DEV Community

Cover image for React Native Accessibility in 2026 — Mapping WCAG 2.2 AA to the Props You Actually Write
Sarah
Sarah

Posted on

React Native Accessibility in 2026 — Mapping WCAG 2.2 AA to the Props You Actually Write

TL;DR

  • The DOJ's ADA Title II rule (WCAG 2.1 AA for state/local gov web + mobile apps) got pushed back. The big-entity deadline is now April 26, 2027, smaller entities April 26, 2028. The EU's Accessibility Act has been live since June 2025 and points at WCAG 2.2 AA. Translation: the clock is still running.
  • React Native maps almost 1:1 onto iOS UIAccessibility and Android AccessibilityNodeInfo. The API is small. The hard part is knowing which prop satisfies which criterion.
  • WCAG 2.2 added four criteria that hit native apps directly: 2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements, 2.5.8 Target Size, 3.3.8 Accessible Authentication.
  • Five bugs cause most failures: unlabeled Pressables, modals that don't trap focus, silent form errors, switches with no state, and text on gradients.
  • Test in four layers: lint in CI → screen reader walkthrough → Xcode Inspector / Android Scanner → Appium + axe for regression.

Let's get into it.

Why you should care (even though the deadline moved)

Quick recap of where things stand as of late 2026:

  • ADA Title II (US): The DOJ's 2024 rule was the first US regulation to name mobile apps explicitly and tie them to a WCAG version (2.1 AA). In April 2026, four days before it was supposed to bite, DOJ extended the dates by a year. Entities serving 50,000+ people now have until April 26, 2027. Everyone else gets until April 26, 2028. The underlying Title II nondiscrimination obligations didn't go anywhere.
  • HHS Section 504: If your app is built for anyone receiving HHS funding (hospitals, clinics, health programs), that rule kept its May 2026 date.
  • European Accessibility Act: In force since June 2025. Covers private-sector stuff like banking, e-commerce, and transport apps, and references EN 301 549, which lines up with WCAG 2.2 AA.
  • Procurement: B2G contracts routinely list WCAG 2.1 AA as a pass/fail checkbox, so even if a regulation doesn't technically cover you, your customer's contract might.
  • Private lawsuits: Title III web and app suits keep coming every year. "We'll fix it later" is not a strategy your legal team will enjoy.

So the practical move hasn't changed: run an accessibility audit on every store build, and be able to show the report when someone asks.

The whole toolbox in one component

WCAG has four principles (Perceivable, Operable, Understandable, Robust) and three conformance levels (A, AA, AAA). For Level AA on native mobile, you're working through the A + AA criteria that apply to apps. A handful of web-only ones (skip links, document heading outline, parsing) don't translate.

Everything else lands on this prop surface:

<Pressable
  accessible
  accessibilityLabel="Save changes"
  accessibilityHint="Saves your draft"
  accessibilityRole="button"
  accessibilityState={{ disabled: false, busy: isSaving }}
  accessibilityValue={{ min: 0, max: 100, now: 42 }}
  importantForAccessibility="yes"
  accessibilityLiveRegion="polite"
>
  <Text>Save</Text>
</Pressable>
Enter fullscreen mode Exit fullscreen mode

That's basically it. (On RN 0.71+ you can also use the ARIA-style aliases like role, aria-label, and aria-busy. Same thing under the hood.)

Now the mapping.

Perceivable + Operable

1.1.1 Non-text Content (A)

Anything meaningful that isn't text needs a label. Anything decorative should disappear from the accessibility tree.

// Meaningful icon button
<Pressable accessibilityRole="button" accessibilityLabel="Delete message" onPress={onDelete}>
  <TrashIcon />
</Pressable>

// Decorative image: hide it on both platforms
<Image
  source={confetti}
  accessible={false}
  accessibilityElementsHidden            // iOS
  importantForAccessibility="no-hide-descendants" // Android
/>
Enter fullscreen mode Exit fullscreen mode

1.3.1 Info and Relationships (A)

Visual structure must be exposed in code. A styled View with onPress is just a blob to a screen reader until you give it a role.

<Text accessibilityRole="header" style={styles.h1}>Account settings</Text>

<Pressable accessibilityRole="link" onPress={openTerms}>
  <Text>Terms of service</Text>
</Pressable>
Enter fullscreen mode Exit fullscreen mode

1.4.3 Contrast (Minimum) (AA)

4.5:1 for body text, 3:1 for large text (18pt+, or 14pt+ bold). Don't eyeball it. Put it in a test so your design tokens can't regress:

// contrast.js
const toRgb = (hex) => {
  const n = parseInt(hex.replace('#', ''), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};

const luminance = (hex) => {
  const [r, g, b] = toRgb(hex).map((v) => {
    v /= 255;
    return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};

export const contrastRatio = (a, b) => {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};
Enter fullscreen mode Exit fullscreen mode
// tokens.test.js
import { contrastRatio } from './contrast';
import { colors } from './theme';

const pairs = [
  ['textPrimary', 'background'],
  ['textSecondary', 'background'],
  ['onPrimary', 'primary'],
];

test.each(pairs)('%s on %s meets AA (4.5:1)', (fg, bg) => {
  expect(contrastRatio(colors[fg], colors[bg])).toBeGreaterThanOrEqual(4.5);
});
Enter fullscreen mode Exit fullscreen mode

Classic RN failure: text over a gradient hero. It passes against the top color and fails against the bottom one. Test against the worst stop, or put a semi-opaque scrim behind the text.

1.4.4 Resize Text + 1.4.10 Reflow (AA)

Text must scale to 200% without clipping. allowFontScaling is on by default, so Dynamic Type and Android font scale already work. What breaks is your layout:

// ❌ clips at large font sizes
<View style={{ height: 48 }}><Text>{title}</Text></View>

// ✅ grows with the text
<View style={{ minHeight: 48, paddingVertical: 12 }}><Text>{title}</Text></View>
Enter fullscreen mode Exit fullscreen mode

If you must cap scaling for a tight UI element, use maxFontSizeMultiplier={1.5} instead of disabling scaling entirely.

2.1.1 Keyboard (A)

On mobile this means hardware keyboards: iPads, foldables, Android with Bluetooth. Every custom gesture needs a non-gesture path, which ties straight into 2.5.7 below.

2.4.3 Focus Order (A) + 2.4.11 Focus Not Obscured (AA, new)

Focus follows render/view-hierarchy order, which usually matches the visual order. It stops matching once you start using position: 'absolute' and zIndex. The usual offender is a custom bottom sheet built as an absolutely positioned View:

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

  {sheetOpen && (
    <View accessibilityViewIsModal style={styles.sheet}>
      <SheetContent />
    </View>
  )}
</>
Enter fullscreen mode Exit fullscreen mode

For 2.4.11, don't let the keyboard cover the focused input:

<KeyboardAvoidingView
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  style={{ flex: 1 }}
>
  <ScrollView keyboardShouldPersistTaps="handled">{/* form */}</ScrollView>
</KeyboardAvoidingView>
Enter fullscreen mode Exit fullscreen mode

2.5.8 Target Size (Minimum) (AA, new)

The floor is 24×24 CSS px. The platform guidelines want 44×44 (iOS) and 48×48 (Android), so aim for those. Icon-only buttons are the repeat offender. hitSlop fixes them without touching the visual design:

<Pressable
  accessibilityRole="button"
  accessibilityLabel="Close"
  hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
  onPress={onClose}
>
  <CloseIcon size={20} />
</Pressable>
Enter fullscreen mode Exit fullscreen mode

2.5.7 Dragging Movements (AA, new)

Reorderable lists need a non-drag alternative. Drag libraries don't hand you this. For screen reader users, custom accessibility actions are the cleanest route:

<View
  accessible
  accessibilityLabel={`${item.title}, position ${index + 1} of ${items.length}`}
  accessibilityActions={[
    { name: 'moveUp', label: 'Move up' },
    { name: 'moveDown', label: 'Move down' },
  ]}
  onAccessibilityAction={({ nativeEvent }) => {
    if (nativeEvent.actionName === 'moveUp' && index > 0) move(index, index - 1);
    if (nativeEvent.actionName === 'moveDown' && index < items.length - 1) move(index, index + 1);
  }}
>
  <DraggableRow item={item} />
</View>
Enter fullscreen mode Exit fullscreen mode

2.5.7 applies to everyone, not just screen reader users, so also ship a visible long-press menu or up/down buttons.

Understandable + Robust

This half of the audit is smaller, but the failures here block people from finishing a signup or a checkout. Those are the ones that end up in demand letters.

3.3.1 Error Identification (A) + 3.3.3 Error Suggestion (AA)

A red border is invisible to VoiceOver. Announce the error and attach it to the field:

const onSubmit = () => {
  if (!isValidEmail(email)) {
    const msg = 'Email is invalid. Use the format name@example.com';
    setEmailError(msg);
    AccessibilityInfo.announceForAccessibility(msg);
    return;
  }
  submit();
};

<TextInput
  value={email}
  onChangeText={setEmail}
  accessibilityLabel={emailError ? `Email, error: ${emailError}` : 'Email'}
/>
{emailError ? <Text style={styles.error}>{emailError}</Text> : null}
Enter fullscreen mode Exit fullscreen mode

3.3.2 Labels or Instructions (A)

Placeholder-only inputs fail, because the placeholder vanishes on focus. Use a persistent visible label:

<Text nativeID="emailLabel">Email</Text>
<TextInput
  accessibilityLabel="Email"            // works on both platforms
  accessibilityLabelledBy="emailLabel"  // Android only
  placeholder="name@example.com"
/>
Enter fullscreen mode Exit fullscreen mode

3.3.8 Accessible Authentication (Minimum) (AA, new)

Let password managers do their job, and never block paste:

<TextInput
  secureTextEntry
  textContentType="password"   // iOS autofill
  autoComplete="password"      // Android autofill
  accessibilityLabel="Password"
/>
Enter fullscreen mode Exit fullscreen mode

Disabling paste "for security" fails 3.3.8 and pushes users toward weaker passwords. Biometric login via expo-local-authentication is a solid extra path.

4.1.2 Name, Role, Value (A)

Every custom control needs all three. Here's a custom toggle:

<Pressable
  onPress={() => setEnabled((v) => !v)}
  accessibilityRole="switch"
  accessibilityLabel="Dark mode"
  accessibilityState={{ checked: enabled }}
>
  <ToggleVisual on={enabled} />
</Pressable>
Enter fullscreen mode Exit fullscreen mode

VoiceOver reads: "Dark mode, switch, on." Drop any one of the three props and you fail 4.1.2.

4.1.3 Status Messages (AA)

Toasts, "Saved!", and loading states should be announced without stealing focus. Android uses live regions. iOS needs an explicit announcement:

function useStatusAnnouncement(message) {
  useEffect(() => {
    if (message && Platform.OS === 'ios') {
      AccessibilityInfo.announceForAccessibility(message);
    }
  }, [message]);
}

function SaveStatus({ message }) {
  useStatusAnnouncement(message);
  return <Text accessibilityLiveRegion="polite">{message}</Text>;
}
Enter fullscreen mode Exit fullscreen mode

The 5 bugs you'll find in almost every audit

# Bug Fix
1 Icon-only Pressable, no label accessibilityLabel + accessibilityRole="button"
2 Custom modal/sheet doesn't trap focus accessibilityViewIsModal (iOS) + hide background with importantForAccessibility="no-hide-descendants" (Android)
3 Form errors are visual-only AccessibilityInfo.announceForAccessibility() on failure
4 Custom switch/checkbox reads as "button" Correct role + accessibilityState={{ checked }}
5 Text on a gradient Test against the worst stop, or add a scrim

The first four are pure "forgot the prop" bugs, and they're the easiest to prevent at the source. Some teams do it with a strict component library that makes the label a required prop. Others use codegen that writes them for you. RapidNative, for example, treats accessibilityLabel, accessibilityRole, and target-safe hitSlop as required on every generated interactive element. Contrast (#5) depends on your palette, so it's the one you'll still want the token test from earlier for.

If you're doing it by hand, a typed wrapper gets you most of the way:

type A11yPressableProps = PressableProps & {
  accessibilityLabel: string; // required, not optional
  accessibilityRole: AccessibilityRole;
};

export function A11yPressable(props: A11yPressableProps) {
  return <Pressable hitSlop={8} {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

Ban raw Pressable in feature code with a lint rule, and TypeScript enforces the rest.

Testing: the 4-layer workflow

Layer 1: Lint in CI.

npm i -D eslint-plugin-react-native-a11y
Enter fullscreen mode Exit fullscreen mode
// .eslintrc.js
module.exports = {
  plugins: ['react-native-a11y'],
  extends: ['plugin:react-native-a11y/all'],
};
Enter fullscreen mode Exit fullscreen mode

This catches missing labels, invalid roles, and unlabeled inputs. It can't see contrast or focus order. No linter can.

Layer 2: Screen reader walkthrough. Turn on VoiceOver (iOS) or TalkBack (Android) and go through each primary flow without looking at the screen. It's about 20 minutes per flow, and it finds more real problems than any other step.

Layer 3: Device audit tools. Xcode → Open Developer Tool → Accessibility Inspector runs an audit against the simulator: contrast, missing labels, hit targets. On Android, use Accessibility Scanner.

Layer 4: Automated regression. axe DevTools Mobile plugs into Appium and runs WCAG rulesets against built apps. It's paid, but it's how you prove compliance at CI speed on a big app.

Cadence that works:

every commit          → Layer 1
every PR touching UI  → Layer 2
every store build     → Layers 3 + 4
Enter fullscreen mode Exit fullscreen mode

For a weekly release train, that's about a 2-hour accessibility pass. Retrofitting a 40-screen app that never had props usually takes weeks.

Where to start today

Don't start with a spreadsheet of 50+ criteria. Start with Layer 2. Turn on VoiceOver or TalkBack, open your app, and try to complete your main flow. Whatever you trip over first is your backlog's #1.

Reference specs if you want to go deeper: WCAG 2.2 and what's new in 2.2.


What's the worst accessibility bug you've found in your own React Native app? Or which WCAG criterion are you still stuck on? Drop it in the comments. I'm reading every one.

Top comments (0)