skip to content

In React Native, how do you move VoiceOver or TalkBack focus to a transfer result heading, and why might focus not land there?

level: seniorimportance: nice to knowfreq 22%

answer

  1. the screen reader's cursor, moved from code
  2. sendAccessibilityEvent(host, 'focus')
  3. setAccessibilityFocus deprecated since 0.85
  4. call after the target has rendered
  5. click ignored on iOS

basics

~20 s

Call AccessibilityInfo.sendAccessibilityEvent with the heading's host instance from a ref and the 'focus' event type, after the heading has rendered; focus fails to land when the ref is null, the target is not accessible, or the platform moves focus afterwards.

solid answer

~40 s

I keep a ref on the result heading, typed `useRef<TextInstance>(null)` in 0.87, and in an effect that runs once the heading has rendered I call `AccessibilityInfo.sendAccessibilityEvent(headingRef.current, 'focus')`. The older `setAccessibilityFocus(reactTag)` has been deprecated since 0.85. When focus does not land, I check four things: the ref was still `null` because the call ran before the heading existed; the target is not an accessibility element, or it sits inside a parent marked `accessible` that is focused as one unit; the screen reader moved focus itself after a screen or modal transition, overriding my call; or I used an event type the platform ignores, since iOS drops `'click'` and only `'focus'` works on both. I move focus only when the control the user was on disappears; otherwise I announce.

code

tsx · 19 lines
tsx
import {useEffect, useRef} from 'react';
import {AccessibilityInfo, Text, type TextInstance} from 'react-native';

export function TransferResultHeading({title}: {title: string}) {
  const headingRef = useRef<TextInstance>(null);

  useEffect(() => {
    const heading = headingRef.current;
    if (heading != null) {
      AccessibilityInfo.sendAccessibilityEvent(heading, 'focus');
    }
  }, [title]);

  return (
    <Text ref={headingRef} accessible accessibilityRole="header">
      {title}
    </Text>
  );
}

go deeper

for a junior

Recall that screen-reader focus can be moved from code with AccessibilityInfo.sendAccessibilityEvent, a ref to a core component and the focus event type.

for a middle

Explain why the call belongs in an effect after the target renders, which event types each platform supports, and that setAccessibilityFocus is deprecated.

for a senior

Diagnose focus that does not land: a null ref, a target merged into an accessible parent, the platform's own focus move after a transition; then verify it with VoiceOver and TalkBack on devices.

for a principal

Set team rules for when a screen may move focus and when it should announce, so focus behaviour is consistent across flows instead of patched per screen.

## The situation After a transfer, the app replaces the form with a result: a heading reading "Transfer completed" and the receipt details. Focus was on the **Send** button, which no longer exists. VoiceOver (iOS) or TalkBack (Android) may put its focus somewhere unhelpful, such as the top of the screen, and the user has to swipe around to find the outcome. Moving **accessibility focus**, the screen reader's cursor rather than keyboard focus, to the heading fixes this. ## The current API ```tsx AccessibilityInfo.sendAccessibilityEvent(headingRef.current, 'focus'); ``` - The first argument is a **host instance**: the native-backed object you get from a `ref` on a core component such as `Text` or `View`. In React Native 0.87, with the Strict TypeScript API on by default, refs are typed with instance types such as `useRef<TextInstance>(null)`. - The second argument is the event type: | Event type | Platforms | Effect | |---|---|---| | `'focus'` | iOS and Android | moves accessibility focus to the view | | `'click'` | Android only (React Native returns early on iOS) | sends an accessibility click event | | `'viewHoverEnter'` | Android only | sends a hover-enter event | | `'windowStateChange'` | Android only | sends a window-state-change event | `AccessibilityInfo.setAccessibilityFocus(reactTag)` is the older API. It took a numeric tag, usually obtained with `findNodeHandle`, and it has been **deprecated since React Native 0.85** in favour of `sendAccessibilityEvent` with `'focus'`. ## Why focus does not land 1. **The ref is still `null`.** The heading exists only after the render that shows the result. Calling from an event handler before that render, or during render, finds no host instance. The call belongs in an effect that runs after the heading is committed, with a `null` check. 2. **The target is not an accessibility element.** The docs ask that a view receiving focus is marked `accessible={true}`. If the heading sits inside a parent marked `accessible`, the parent is focused as one unit and its children are read as part of it, so focus the parent or restructure. 3. **The platform moves focus after you do.** When a new screen or a modal appears, the screen reader places focus itself as the transition settles, which can override a call made on mount. Send the event once the transition has finished, not in the first effect of a freshly pushed screen. 4. **The wrong event type.** `'click'` does nothing on iOS, and the other types are Android-only. `'focus'` is the one type documented to move focus on both platforms. 5. **The view was replaced.** If the heading remounts after the call, for example because a parent's `key` changed, the new native view never received the event. ## When to move focus and when to announce | Situation | Better tool | |---|---| | The result replaces the control the user was on | move focus to the result heading | | A confirmation appears and the user stays on the form | `announceForAccessibility`, or a live region on Android | | A request fails and the form stays | announce the failure, keep focus on the form | Moving focus is powerful and disruptive: the user loses their place in the screen. Use it when the old focus target disappears or the next step is somewhere else, not for every status change. ## Verifying it - A Jest test can assert that `sendAccessibilityEvent` was called with the right instance and `'focus'`, which catches the `null`-ref and wrong-event-type bugs. - It cannot prove that VoiceOver or TalkBack actually moved. Check on real devices with each screen reader on: submit the transfer, confirm the reader speaks the heading, and confirm that the next swipe continues into the receipt details. - Repeat on both platforms, because VoiceOver and TalkBack handle focus after a screen change differently, and a fix proven on one says nothing about the other.

  • What did setAccessibilityFocus take, and what replaces it in current React Native?
    It took a numeric React tag, usually from `findNodeHandle(ref.current)`, and it has been deprecated since 0.85. The replacement is `AccessibilityInfo.sendAccessibilityEvent(ref.current, 'focus')`, which receives the host instance directly, so no tag lookup is needed.
  • Why not move focus to the status line after every update on the transfer screen?
    Each move takes the user out of wherever they were reading, so frequent moves make the screen hard to follow. Move focus when the control the user was on disappears or the next step lives elsewhere, as when the form is replaced by the result. For a confirmation that leaves the form in place, announce it instead.

saying these in an interview costs you the question

  • setAccessibilityFocus with findNodeHandle is the current way to move screen-reader focus.
  • sendAccessibilityEvent with 'click' behaves the same on iOS and Android.
  • Calling sendAccessibilityEvent during render works because the ref is already set.
  • Moving focus after every state change is the best way to keep users informed.
  • A Jest test that the call happened proves VoiceOver and TalkBack actually moved.