skip to content

Custom Actions & Grouping

accessibilityActions exposes swipe-only operations such as delete to screen reader users, and accessible merges children into one focus stop. Interviewers ask how a swipeable row stays usable.

part ofReact Nativeoverview, primer and where to startread it →
on this pageshow

explore

questions

5

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.
open as a page

In React Native, what does accessible={true} on a View do to the elements inside it for VoiceOver and TalkBack?

level: juniorimportance: should knowfreq 42%

basics

~20 s

It turns the View into a single accessibility element, so the screen reader focuses the group once and reads its children together instead of stopping on each child; Pressable and the other touchables are already accessible by default.

open as a page

In React Native, how do you stop VoiceOver and TalkBack from reaching the inbox behind a custom bottom sheet drawn in the same view tree?

level: middleimportance: should knowfreq 25%

basics

~10 s

Hide the background: aria-hidden={true} on the inbox container sets accessibilityElementsHidden on iOS and importantForAccessibility no-hide-descendants on Android; on iOS, accessibilityViewIsModal on the sheet also works, but only against the sheet's siblings.

open as a page

In React Native, what decides the order VoiceOver and TalkBack move through an email row, and how do you change it when it reads wrong?

level: seniorimportance: should knowfreq 22%

basics

~20 s

By default the order comes from the native view hierarchy and layout, so fix it by ordering the JSX the way it should be read, grouping the row with an explicit label, or, experimentally, listing nativeIDs in experimental_accessibilityOrder.

open as a page

In React Native, what do onMagicTap and onAccessibilityEscape do, and where would you use them in an email app?

level: middleimportance: nice to knowfreq 12%

basics

~20 s

They are iOS-only VoiceOver gesture handlers: onMagicTap runs on a two-finger double-tap for the screen's most important action, such as sending a draft, and onAccessibilityEscape runs on a two-finger scrub to go back or dismiss, such as closing the compose sheet.

open as a page