skip to content

In React Native, how do you make a swipe-to-archive email row usable for VoiceOver and TalkBack users who cannot perform the swipe?

level: seniorimportance: must knowfreq 40%

answer

  1. a one-finger swipe moves the reader
  2. accessibilityActions: name plus label
  3. onAccessibilityAction and nativeEvent.actionName
  4. keep onPress for double-tap
  5. a visible alternative for non-reader users

basics

~10 s

Declare the swipe operations as custom actions with accessibilityActions, such as archive with a label, and run them in onAccessibilityAction by switching on event.nativeEvent.actionName; screen-reader users then pick Archive from the row's action list.

solid answer

~50 s

With VoiceOver or TalkBack on, a one-finger swipe moves focus to the next element, so the row's swipe gesture is unreachable. I keep the row as one element and declare its hidden operations with `accessibilityActions={[{name: 'archive', label: 'Archive'}, {name: 'toggleRead', label: 'Mark as read'}]}`, then handle them in `onAccessibilityAction` by switching on `event.nativeEvent.actionName` and calling the same functions the swipe calls. VoiceOver offers them in its Actions rotor and TalkBack in its actions menu, using the `label`. Opening the email stays on `onPress`, which is what a double-tap triggers. I keep the action list in sync with what the swipe offers, make labels reflect state, confirm the result in words, and still add a visible path such as a long-press menu, because people who cannot swipe for motor reasons may not run a screen reader.

code

tsx · 36 lines
tsx
import {Pressable, Text, type AccessibilityActionEvent} from 'react-native';

type EmailRowProps = {
  sender: string;
  subject: string;
  unread: boolean;
  onOpen: () => void;
  onArchive: () => void;
  onToggleRead: () => void;
};

export function EmailRow({sender, subject, unread, onOpen, onArchive, onToggleRead}: EmailRowProps) {
  const handleAction = (event: AccessibilityActionEvent) => {
    switch (event.nativeEvent.actionName) {
      case 'archive':
        onArchive();
        break;
      case 'toggleRead':
        onToggleRead();
        break;
    }
  };

  return (
    <Pressable
      onPress={onOpen}
      accessibilityActions={[
        {name: 'archive', label: 'Archive'},
        {name: 'toggleRead', label: unread ? 'Mark as read' : 'Mark as unread'},
      ]}
      onAccessibilityAction={handleAction}>
      <Text>{sender}</Text>
      <Text>{subject}</Text>
    </Pressable>
  );
}

go deeper

for a junior

Recall that screen readers take over one-finger swipes, and that accessibilityActions with onAccessibilityAction expose hidden operations as a list.

for a middle

Explain the name and label fields, the standard action names such as activate, and how the handler reads event.nativeEvent.actionName.

for a senior

Show how you keep actions in sync with the gesture, write state-aware labels, confirm outcomes, and verify with VoiceOver's rotor and TalkBack's menu on devices.

for a principal

Argue for a shared swipeable-row component that generates its accessibility actions from the same configuration as its gestures, so the two cannot drift apart.

## Why the swipe is unreachable A **swipe-to-archive** row hides an operation behind a horizontal drag. With a screen reader running, touch works differently: on both **VoiceOver** and **TalkBack**, a one-finger swipe left or right moves focus to the previous or next element instead of reaching the app. The row's gesture never fires, so archiving is simply missing for a screen-reader user. The fix is not to detect the screen reader and render different rows; it is to describe the row's operations to the screen reader so it can offer them. ## Custom actions in React Native React Native exposes this through two props on `View`, `Pressable` and the components built on them: - `accessibilityActions`: an array of `{name, label}` objects. `name` is the identifier your code receives; `label` is the localized text the screen reader presents. For a **custom** action the label is what the user hears, so always provide it. - `onAccessibilityAction`: a handler that receives an event whose `nativeEvent.actionName` is the `name` of the action the user chose. The screen reader surfaces the list in its own way: VoiceOver through its **Actions** rotor on the focused element, TalkBack through its **actions** menu. Either way, the user focuses the row, picks "Archive", and your handler runs. ## Standard action names Some names are **standard** and are generated by gestures the screen reader already has: | Name | Platform | Generated when | |---|---|---| | `activate` | both | the user double-taps the focused element | | `increment`, `decrement` | both | the user adjusts an element with the adjustable role | | `longpress` | Android | the user double-taps and holds | | `magicTap` | iOS | a two-finger double-tap on or inside the element | | `escape` | iOS | a two-finger scrub gesture | | `expand`, `collapse` | Android | TalkBack's expand and collapse actions | For the email row, opening the message is the ordinary double-tap and can stay on `onPress`. The operations that only the swipe offered, archive, mark as read, move to folder, become **custom** actions with names you choose. ## Implementing it well 1. **Keep the row one element.** A `Pressable` row is already an accessibility element; the actions attach to it, so the user never hunts for hidden buttons. 2. **Mirror the gesture exactly.** Every operation the swipe exposes should be an action, calling the same function. If the swipe gains "Snooze", the action list gains it too. 3. **Make labels state-aware.** "Mark as read" on an unread row, "Mark as unread" on a read one. The label is the only description the user gets. 4. **Confirm the outcome.** Archiving removes the row, and focus moves on to whatever comes next; say what happened, with an undo path, so the user is not left guessing. 5. **Offer a visible alternative too.** Custom actions reach screen-reader users. People who cannot perform a swipe for motor reasons and do not run a screen reader need another way in, such as a long-press menu or an overflow button. ## Common mistakes - Handling the gesture's visual reveal but never declaring actions, so the operation exists only for people who can swipe. - Declaring actions without labels, so the screen reader has nothing meaningful to announce. - Declaring actions on a grouped row but also leaving nested buttons inside it, which duplicates operations and leaves the nested ones unreachable. - Declaring actions but not handling every `name` in `onAccessibilityAction`, so picking one does nothing. ## Verifying Turn on VoiceOver, focus a row, open the Actions rotor and archive; then repeat with TalkBack and its actions menu. A test can also fire the accessibility action on the row and assert that the archive function was called, but the device pass is what proves the labels read well.

  • Why not just render visible Archive buttons on every row when a screen reader is detected?
    It forks the UI on a flag and adds one or more focus stops per row, so a list of fifty emails costs far more swipes. Custom actions keep one stop per row while still offering every operation. A visible alternative such as a long-press menu is still worth adding for everyone, not only when a screen reader is on.
  • How do you make the action label reflect the row's state?
    Build the `accessibilityActions` array from props on each render, for example `label: unread ? 'Mark as read' : 'Mark as unread'`, while keeping the `name` stable so `onAccessibilityAction` needs one case. The label is the only text the user hears for a custom action, so it must describe what will happen.

saying these in an interview costs you the question

  • A screen-reader user can still perform the row's horizontal swipe gesture.
  • Custom actions need no label because the name is read out.
  • Put a separate hidden Archive button inside the grouped row instead of actions.
  • Map the double-tap to archive so the operation is one gesture away.
  • Custom actions make a visible alternative unnecessary for every user.