skip to content

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%

answer

  1. the tree and layout set the default
  2. absolute positioning moves pixels, not order
  3. reorder JSX first
  4. group with an explicit label
  5. experimental_accessibilityOrder with nativeID

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.

solid answer

~50 s

By default VoiceOver and TalkBack derive the order from the native view hierarchy and its layout, which come from how the JSX is written and styled. The usual defect is a view placed early in the tree and moved visually with `position: 'absolute'`, such as a timestamp written before the sender and pinned top-right, so the reader says the time first. My first fix is to order the JSX in reading order and get the visual placement from flex layout. For a row that should be one stop, I group it with `accessible` and write an explicit label in the intended order, because the label iOS composes from children follows the tree. Since 0.80 there is also `experimental_accessibilityOrder`, an array of `nativeID`s on a container: it is exhaustive, dropping unreferenced elements, it does not make a referenced view accessible, and it is experimental.

code

tsx · 22 lines
tsx
import {Pressable, StyleSheet, Text, View} from 'react-native';

type Props = {sender: string; subject: string; time: string; onOpen: () => void};

// Reading order in the tree: sender, then time, then subject.
// Flex layout puts the time on the right without taking it out of flow.
export function EmailRow({sender, subject, time, onOpen}: Props) {
  return (
    <Pressable onPress={onOpen} accessibilityLabel={`From ${sender}. ${subject}. Received ${time}`}>
      <View style={styles.header}>
        <Text style={styles.sender}>{sender}</Text>
        <Text>{time}</Text>
      </View>
      <Text>{subject}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  header: {flexDirection: 'row', justifyContent: 'space-between'},
  sender: {flexShrink: 1},
});

go deeper

for a junior

Recall that the reading order follows the view tree and layout, so writing JSX in reading order usually gets it right.

for a middle

Explain how absolute positioning splits visual order from tree order, and how grouping with an explicit label fixes a row's phrasing.

for a senior

Diagnose a row that reads out of order on one platform, choose between reordering, grouping and experimental_accessibilityOrder, and verify with both readers.

for a principal

Decide whether shared row components should own their reading order and labels, so feature teams cannot break the order with a styling change.

## Where the default order comes from When a **VoiceOver** (iOS) or **TalkBack** (Android) user swipes through a screen, the reader moves from one accessibility element to the next in an order it derives from the **native view hierarchy and its layout**. In React Native, that hierarchy comes from your JSX: children are created in the order you write them, and styles decide where they end up on screen. The React Native docs say it plainly: layout dictates the default focus order. Most of the time, writing the JSX top to bottom and left to right gives the right reading order for free. ## How it goes wrong The order breaks when the **tree order and the visual order disagree**: - A timestamp is written first in the JSX and pinned to the top-right corner with `position: 'absolute'`. Visually it reads last; the reader may announce it first. - An unread badge is overlaid on the avatar with absolute positioning, so the reader reaches it separately and out of context. - A row's text sits in nested containers built for layout convenience, so the reader treats pieces as separate elements in container order. Because each platform's reader builds its order in its own way, a row can read correctly under one screen reader and wrongly under the other. Always check both. ## The fixes, in order of preference 1. **Write the JSX in reading order.** Achieve the visual arrangement with flex layout (`flexDirection`, `justifyContent`, `alignSelf`) instead of pulling elements out of flow. This fixes the order for every reader at once and costs nothing. 2. **Group the row and label it.** When the row should be a single stop anyway, make it one element (a `Pressable` row already is, or `accessible` on a `View`) and give it an explicit label in the order you want, for example "Unread. From Ana. Invoice for March. Received 9:41". On iOS, a group without a label gets one joined from its children's text in tree order, so the explicit label is what fixes the phrasing. 3. **Use `experimental_accessibilityOrder` when the tree cannot change.** It is an array of `nativeID` strings set on a container, and the reader focuses the referenced descendants in that order. ## How `experimental_accessibilityOrder` behaves This prop was added for iOS and Android in React Native 0.80 and is still **experimental**: the docs mark it so, and it is not part of the public TypeScript API in 0.87, so a typed project has to work around the missing type. Its rules are precise: | Rule | Consequence | |---|---| | It lists `nativeID`s of descendants | give each target a `nativeID` | | It is **exhaustive** | an accessible descendant that is not listed is never focused | | It does **not** turn accessibility on | a listed view without `accessible` is skipped | | A listed **container** keeps its default inner order | a non-accessible view with accessible children expands in place | | It **nests** | a listed container may carry its own order array | | A view cannot be both container and element | an `accessible` view with an order array hides its children | The exhaustive rule is the trap: add a new element to the row, forget to list it, and screen-reader users never reach it. ## Hiding what should not be read Part of fixing order is removing noise. A decorative divider or an icon that repeats text already read belongs outside the accessibility tree, which on a `View` is `aria-hidden`, so the reader moves from one meaningful element to the next. ## Verifying Swipe through the row, and the screen around it, with VoiceOver and with TalkBack. Write down what each swipe says and compare it with what a sighted user reads. Do this after every layout change to the row, because a styling change can silently reorder what the reader hears.

  • A new Flag icon was added to a row that uses experimental_accessibilityOrder, and screen-reader users never reach it. Why?
    The prop is exhaustive: accessible descendants of the container that are not listed by `nativeID` are never focused. Adding the element to the JSX is not enough; its `nativeID` must also be added to the order array, and it must be accessible, because the prop does not turn accessibility on.
  • Why prefer reordering the JSX over experimental_accessibilityOrder?
    Reordering fixes the order on both platforms with a stable, typed, ordinary tree, and it cannot silently drop new elements. `experimental_accessibilityOrder` is experimental, missing from the public TypeScript API in 0.87, and exhaustive, so it adds a list someone must keep in sync. Reserve it for layouts where the tree genuinely cannot match the reading order.

saying these in an interview costs you the question

  • Screen readers always read elements in their on-screen position, whatever the tree order.
  • position: 'absolute' changes the reading order along with the visual position.
  • experimental_accessibilityOrder also makes the views it lists accessible.
  • Views left out of experimental_accessibilityOrder are read after the listed ones.
  • If the order is right under VoiceOver it is right under TalkBack.