Testing It

Turning on VoiceOver and TalkBack, minimum touch targets, surviving the largest text size, and honouring reduce motion.

SyntaxAccessibilityInfo.isReduceMotionEnabled(): Promise<boolean>

Accessibility cannot be checked by reading code. You turn the screen reader on, put the mouse down and listen.

Turning them on

iOS: Settings > Accessibility > VoiceOver. Set Settings > Accessibility > Accessibility Shortcut to VoiceOver first, so a triple-click of the side button toggles it; navigating with it on is slow until you learn it. Swipe right moves to the next element, double tap activates. VoiceOver does not run in the iOS Simulator, so this needs a real device. Xcode's Accessibility Inspector checks labels and roles on the Simulator but not the speech. Android: Settings > Accessibility > TalkBack, on a device or an emulator image that includes Google Play. The gestures are the same.

Touch targets

Apple asks for 44x44 points, Android for 48x48 density-independent pixels. A 16pt glyph in a tight row fails both. minWidth and minHeight grow the box; hitSlop extends the touch area without changing layout or the visible outline.

Larger text

Both systems let users scale text, and allowFontScaling is true by default, so your text grows whether you planned for it or not. Test at the largest setting. Cap a single Text with maxFontSizeMultiplier, and read fontScale from useWindowDimensions when a layout has to change shape rather than just stretch.

Reduce motion

AccessibilityInfo.isReduceMotionEnabled() returns a promise of a boolean. AccessibilityInfo.addEventListener("reduceMotionChanged", handler) returns a subscription with .remove() for changes while the app runs. Honour it by shortening or dropping a transition, never by skipping the state change it was showing.

Example

Example · tsx
// On screen: a Help button that opens a full-screen sheet. The sheet
// slides up normally, and appears instantly with no slide when the user
// has Reduce Motion turned on in system settings.
import { useEffect, useState } from "react";
import { AccessibilityInfo, Modal, Pressable, StyleSheet, Text, View } from "react-native";

export default function HelpSheet() {
  const [visible, setVisible] = useState(false);
  const [reduceMotion, setReduceMotion] = useState(false);

  useEffect(() => {
    // Read the current setting once on mount.
    AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
    // Then follow it, in case the user changes it while the app is open.
    const subscription = AccessibilityInfo.addEventListener(
      "reduceMotionChanged",
      setReduceMotion,
    );
    return () => subscription.remove();
  }, []);

  return (
    <View style={styles.screen}>
      <Pressable accessibilityRole="button" onPress={() => setVisible(true)} style={styles.button}>
        <Text style={styles.label}>Help</Text>
      </Pressable>
      <Modal
        visible={visible}
        animationType={reduceMotion ? "none" : "slide"}
        onRequestClose={() => setVisible(false)}
      >
        <View style={styles.sheet}>
          <Text style={styles.body}>Orders ship within two working days.</Text>
          <Pressable accessibilityRole="button" onPress={() => setVisible(false)} style={styles.button}>
            <Text style={styles.label}>Close</Text>
          </Pressable>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16 },
  sheet: { flex: 1, padding: 24, gap: 16, justifyContent: "center" },
  button: { minHeight: 44, justifyContent: "center" },
  label: { fontSize: 16, fontWeight: "600", color: "#1f6feb" },
  body: { fontSize: 16 },
});

When to use it

  • Walk a finished screen with VoiceOver on and find the controls that announce nothing.
  • Check a dense toolbar against the 44pt and 48dp minimums before review.
  • Stop a settings screen clipping its own text at the largest accessibility text size.
  • Skip a slide-in transition for users who have Reduce Motion turned on.

More examples

A tap target that is bigger than it looks

A small pill with a remove cross, where the cross is only 20pt across but accepts a touch 44pt wide. This is the standard fix for dense rows of chips, table-row delete buttons and close crosses that a thumb keeps missing. Where you control the design, prefer a real 44pt box, because hitSlop regions of neighbouring controls can overlap and then the top one wins.

Example · tsx
import { Pressable, StyleSheet, Text, View } from "react-native";

type TagProps = {
  label: string;
  onRemove: () => void;
};

export default function Tag({ label, onRemove }: TagProps) {
  return (
    <View style={styles.tag}>
      <Text style={styles.text}>{label}</Text>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={`Remove ${label}`}
        onPress={onRemove}
        // The glyph is 12pt. hitSlop grows the touch area by 12pt on each
        // side without changing layout. It does not change the visible
        // box or the screen reader's focus outline, so use it to make a
        // small control reachable, not to make a small control correct.
        hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
        style={styles.remove}
      >
        <Text style={styles.glyph}>×</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  tag: { flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 10, paddingVertical: 6, borderRadius: 14, backgroundColor: "#eee", alignSelf: "flex-start" },
  text: { fontSize: 14 },
  remove: { width: 20, height: 20, alignItems: "center", justifyContent: "center" },
  glyph: { fontSize: 12, lineHeight: 12, color: "#444" },
});

Reacting to the system text size

A label-and-value row that becomes a stacked pair at large text sizes. fontScale from useWindowDimensions updates when the user changes the setting, so the component re-renders without any listener of your own. Use this pattern for settings rows, receipt lines and anything else that relies on two pieces of text sharing one line.

Example · tsx
import { StyleSheet, Text, View, useWindowDimensions } from "react-native";

type StatRowProps = {
  label: string;
  value: string;
};

export default function StatRow({ label, value }: StatRowProps) {
  const { fontScale } = useWindowDimensions();
  // Past roughly 1.3x the label and value no longer fit side by side,
  // so stack them instead of letting one truncate.
  const stacked = fontScale > 1.3;

  return (
    <View style={[styles.row, stacked && styles.stacked]}>
      {/* allowFontScaling is true by default. Setting it to false freezes
          text at its design size and is almost always the wrong fix. */}
      <Text style={styles.label} maxFontSizeMultiplier={2}>
        {label}
      </Text>
      <Text style={styles.value} maxFontSizeMultiplier={2}>
        {value}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  // minHeight, not height: the row has to be allowed to grow.
  row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", minHeight: 44, paddingHorizontal: 16, gap: 12 },
  stacked: { flexDirection: "column", alignItems: "flex-start" },
  label: { fontSize: 15, color: "#555" },
  value: { fontSize: 15, fontWeight: "600" },
});

Announcing a change the user is not focused on

A filter toggle with a result count beneath it. Sighted users see the number change; a screen reader user whose focus is still on the button hears nothing, which is the single most common thing a test with VoiceOver on uncovers. announceForAccessibility covers iOS, accessibilityLiveRegion covers Android. Use it sparingly, for results counts, validation summaries and save confirmations.

Example · tsx
import { useState } from "react";
import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View } from "react-native";

const TOTAL = 24;

export default function ResultCount() {
  const [count, setCount] = useState(TOTAL);

  function toggleFilter() {
    const next = count === TOTAL ? 6 : TOTAL;
    setCount(next);
    // iOS has no live region: nothing is announced unless you say it.
    // On Android the live region below already speaks, so guard by
    // platform or the change is announced twice.
    if (Platform.OS === "ios") {
      AccessibilityInfo.announceForAccessibility(`${next} results`);
    }
  }

  return (
    <View style={styles.box}>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel="Toggle unread filter"
        onPress={toggleFilter}
        style={styles.button}
      >
        <Text style={styles.label}>Unread only</Text>
      </Pressable>
      {/* Android only: TalkBack re-reads this Text when it changes. */}
      <Text accessibilityLiveRegion="polite" style={styles.count}>
        {`${count} results`}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { padding: 16, gap: 8 },
  button: { minHeight: 44, justifyContent: "center" },
  label: { fontSize: 16, fontWeight: "600" },
  count: { fontSize: 14, color: "#555" },
});

Discussion

  • Be the first to comment on this lesson.