# Accessibility

The React Native SDK ships an opt-in accessibility layer for VoiceOver (iOS) and TalkBack (Android). When enabled, components add the appropriate `accessibilityRole`, `accessibilityState`, `accessibilityLabel`, `accessibilityValue`, and `accessibilityLiveRegion` attributes; the SDK announces incoming messages, AI typing transitions, and connection-state changes through a single imperative announcer; modal/sheet surfaces apply the platform-correct focus-trap props; and high-traffic surfaces like `MessageList` expose a "Scroll to bottom" rotor action so screen-reader users can jump to the latest messages without swiping through the entire list.

Accessibility is **off by default**. Existing integrations behave exactly as before until you opt in.

## Best Practices

- Pass `accessibility={{ enabled: true }}` to [`OverlayProvider`](https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/), not to `Chat`.
- Test on both iOS (VoiceOver) and Android (TalkBack) before shipping a release.
- When you author custom components, use `useA11yLabel('a11y/...')` instead of `t('a11y/...')` so labels short-circuit when an integrator opts out.
- Override `a11y/*` keys via `Streami18n` rather than hardcoding English strings in component code.
- When you replace an SDK component via `WithComponents`, the SDK no longer drives that component's accessibility — read `useAccessibilityContext()` if you want to mirror the SDK's behavior.
- Don't subscribe to `useScreenReaderEnabled()` inside list-item components — toggling the screen reader will re-render every row.
- Use `forceScreenReaderMode: true` during development to exercise accessible variants without enabling VoiceOver/TalkBack on the device.
- Avoid wrapping the announcer twice; do not mount a second `AccessibilityProvider` if you are already inside `OverlayProvider`.
- Hide decorative icons inside labeled controls using `accessibilityElementsHidden` (iOS) and `importantForAccessibility="no-hide-descendants"` (Android).

## Enabling Accessibility

Pass an `accessibility` config to `OverlayProvider`. Defaults take over once `enabled` is true — there is nothing else to wire up.

```tsx
import { StreamChat } from "stream-chat";
import { Chat, OverlayProvider } from "stream-chat-react-native";

const client = StreamChat.getInstance("api_key");

export const App = () => (
  <OverlayProvider accessibility={{ enabled: true }}>
    <Chat client={client}>{/** App components */}</Chat>
  </OverlayProvider>
);
```

>
> **Note:** Every other field in `AccessibilityConfig` is ignored unless `enabled` is `true`. Keeping the master switch off preserves the SDK's previous behavior bit-for-bit.
>

## Configuration Reference

| Prop                           | Description                                                                                                                                                                                                   | Type                                        | Default  |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -------- |
| `enabled`                      | Master toggle. When `false` no announcer mounts, no listeners attach, and `useA11yLabel` returns `undefined` so `t()` is skipped on hot paths.                                                                | `boolean`                                   | `false`  |
| `forceScreenReaderMode`        | Forces "screen reader on" UI even when no screen reader is active. Useful during development.                                                                                                                 | `boolean`                                   | `false`  |
| `announceNewMessages`          | Announces incoming messages through the live-region announcer (throttled and batched).                                                                                                                        | `boolean`                                   | `true`   |
| `announceTypingIndicator`      | Announces typing transitions. Off by default — TalkBack and VoiceOver chatter on mobile is more disruptive than helpful for most chat surfaces.                                                               | `boolean`                                   | `false`  |
| `announceConnectionState`      | Announces offline/online/reconnecting transitions.                                                                                                                                                            | `boolean`                                   | `true`   |
| `audioRecorderTapMode`         | Whether the audio recorder swaps its hold-to-record gesture for a tap-toggle alternative. See [Gesture Alternatives](#gesture-alternatives).                                                                  | `'auto' \| 'always' \| 'never'`             | `'auto'` |
| `imageGalleryScreenReaderMode` | Whether the image gallery swaps its multi-touch gestures for tap-driven controls. See [Gesture Alternatives](#gesture-alternatives).                                                                          | `'auto' \| 'always' \| 'never'`             | `'auto'` |
| `messageActionsTrigger`        | Whether to render an extra "More actions" button next to messages. `'auto'` shows it when a screen reader is active; `'always-button'` shows it for everyone; `'long-press'` keeps the gesture-only behavior. | `'long-press' \| 'auto' \| 'always-button'` | `'auto'` |

`announceTypingIndicator` defaults to `false` because mobile screen readers (especially TalkBack on Android) interrupt aggressively when a live region updates — announcing every keystroke from another participant tends to make a chat feel hostile rather than informative. Turn it on if your audience expects every typing transition called out, but test on a real device first.

## Gesture Alternatives

Mobile gestures (long-press menus, hold-to-record audio, pinch/pan in the image gallery) are inaccessible to screen-reader users. The SDK exposes three knobs that control whether equivalent tap-driven UI is shown:

- `'auto'` — render the accessible variant when a screen reader is detected via `AccessibilityInfo`. Sighted users see no change.
- `'always'` — render the accessible variant for everyone. Useful when you want the same UI in every state.
- `'never'` — the SDK never swaps UI. Pick this when you ship a fully custom component via `WithComponents` and own the accessibility yourself.

`messageActionsTrigger` is wired end-to-end today. `audioRecorderTapMode` and `imageGalleryScreenReaderMode` are accepted but no-op until their underlying UI swaps ship; setting them now is safe and will take effect automatically once the SDK lands the corresponding UI.

## Localization

All accessibility strings flow through `Streami18n` under the `a11y/*` namespace. The English defaults ship in every locale; override per-key the same way you override any other translation. See the [Localization guide](https://getstream.io/chat/docs/sdk/react-native/guides/localization/) for the full setup.

```tsx
import { Streami18n } from "stream-chat-react-native";

const i18n = new Streami18n({ language: "nl" });

i18n.registerTranslation("nl", {
  "a11y/Avatar of {{name}}": "Avatar van {{name}}",
  "a11y/{{count}} new messages": "{{count}} nieuwe berichten",
  "a11y/New message from {{user}}": "Nieuw bericht van {{user}}",
});
```

The namespace covers labels for SDK buttons across the composer, attachment picker, image gallery, polls, message actions, and live-region announcements. The canonical list lives in [`package/src/i18n/en.json`](https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/i18n/en.json) — search for keys prefixed with `a11y/` to see what is overridable.

### Localizing custom buttons

The SDK's `Button` component accepts an `accessibilityLabelKey` (and optional `accessibilityLabelParams`) so the rendered accessibility label is resolved through the same translation pipeline. Prefer this over a hard-coded `accessibilityLabel` when you build SDK-style icon-only buttons:

```tsx
import { Button, SendRight } from "stream-chat-react-native";

<Button
  accessibilityLabelKey="a11y/Send message"
  iconOnly
  LeadingIcon={SendRight}
  type="solid"
  variant="primary"
  onPress={onSend}
/>;
```

The legacy `accessibilityLabel` prop still works as a fallback when `accessibilityLabelKey` is omitted. `AttachmentRemoveControl` exposes the same `accessibilityLabelKey` / `accessibilityLabelParams` props for the per-attachment remove icon.

## Programmatic Announcements

Use `useAccessibilityAnnouncer()` to speak custom events that don't have a built-in SDK trigger — typically when your own components resolve a state that screen-reader users should hear about.

```tsx
import { useAccessibilityAnnouncer } from "stream-chat-react-native";

const PermissionPrompt = () => {
  const announce = useAccessibilityAnnouncer();

  const onGranted = () => {
    announce("Microphone access granted", "polite");
  };

  return /* ... */;
};
```

For state-driven announcements that change as a value transitions (a loading state moving through `pending → success → error`, for example), use `useAnnounceOnStateChange`. It dedupes consecutive identical messages and applies a built-in debounce so the screen reader is not flooded.

```tsx
import { useAnnounceOnStateChange } from "stream-chat-react-native";

const UploadStatus = ({ status }) => {
  const message =
    status === "uploading"
      ? "Uploading"
      : status === "done"
        ? "Upload complete"
        : status === "failed"
          ? "Upload failed"
          : null;

  useAnnounceOnStateChange(message);

  return /* ... */;
};
```

Both hooks are no-ops when `accessibility.enabled` is `false`, so it is safe to call them unconditionally.

### Announcing incoming messages on a custom list

`MessageList` and `MessageFlashList` already wire incoming-message announcements internally — you do not need to do anything special when you use them. If you build a custom message list (or a custom thread view), call `useIncomingMessageAnnouncements` to opt back into the same throttled, batched announcer behavior.

```tsx
import {
  useChannelContext,
  useChatContext,
  useIncomingMessageAnnouncements,
} from "stream-chat-react-native";

const CustomMessageList = () => {
  const { channel } = useChannelContext();
  const { client } = useChatContext();

  useIncomingMessageAnnouncements({
    channel,
    ownUserId: client.userID,
    // For a thread view: pass `threadList: true` and the active `parent_id`
    // as `activeThreadId` so only thread replies trigger announcements.
  });

  return /* ... */;
};
```

The hook deduplicates by message id, throttles to one announcement per second, and batches multiple incoming messages into a `"{{count}} new messages"` summary. It subscribes to `channel.on('message.new')` only when `accessibility.enabled` and `accessibility.announceNewMessages` are both true.

## Focusing the Composer on Entry

When accessibility is enabled, the SDK moves the screen reader cursor onto the message composer's input as the message view mounts - so screen reader users land where they act instead of on an arbitrary focus stop. This only moves accessibility focus; the keyboard does not open and the field is not activated (the user still double taps to start typing).

Mount time focus covers Android (TalkBack) forward navigation. iOS (VoiceOver) forward navigation and back navigation on both platforms, need focus set **after the screen transition finishes** — a focus request made mid-transition loses a race with the OS's own screen-entry focus pass. Transition timing belongs to your navigation library, so wire it there using the public `useSetAccessibilityFocus` hook together with `Channel`'s `setInputRef` prop:

```tsx
import { useCallback, useRef } from "react";
import { TextInput } from "react-native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { Channel, useSetAccessibilityFocus } from "stream-chat-react-native";

const MessageScreen = ({ channel }) => {
  const inputRef = useRef<TextInput | null>(null);
  const navigation = useNavigation();
  const setAccessibilityFocus = useSetAccessibilityFocus();

  // Stable — an inline arrow would re-fire the ref on every re-render.
  const setInputRef = useCallback((ref: TextInput | null) => {
    inputRef.current = ref;
  }, []);

  useFocusEffect(
    useCallback(
      () =>
        navigation.addListener("transitionEnd", (e) => {
          if (!e.data.closing) setAccessibilityFocus(inputRef);
        }),
      [navigation, setAccessibilityFocus],
    ),
  );

  return (
    <Channel channel={channel} setInputRef={setInputRef}>
      {/* MessageList, MessageComposer, ... */}
    </Channel>
  );
};
```

`useSetAccessibilityFocus()` returns a stable callback that moves the screen-reader cursor onto a ref (or a raw node handle) and is a no-op unless a screen reader is running. It is generic — point it at any element you want focused, not just the composer.

## Keeping the Header Reachable

Because the composer now takes initial focus, make sure your screen header (title, back button) stays easy to reach:

- **Mark the header title as a heading** — `accessibilityRole="header"`. Screen-reader users then jump straight to it: VoiceOver via the Headings rotor (or the four-finger tap at the top of the screen), TalkBack via the Headings reading control followed by a swipe up.
- **Handle the iOS back gesture on a custom header** — the VoiceOver two-finger "escape" scrub fires `onAccessibilityEscape`; point it at your back action. Android's system Back gesture already routes through your navigation library.

```tsx
<View
  // Required on the New Architecture: a layout only View is flattened to no native
  // view, so onAccessibilityEscape would never receive the gesture (see note below).
  collapsable={false}
  onAccessibilityEscape={() => navigation.goBack()}
  style={{ flex: 1 }}
>
  {/* header + Channel */}
</View>
```

>
> **Warning:** Under the New Architecture (Fabric), `onAccessibilityEscape` is silently ignored on a plain layout only `View`: Fabric flattens it to no backing native view, so the gesture is never delivered (VoiceOver plays its "nothing to escape" sound). Add `collapsable={false}` to the view hosting the handler. There is no equivalent `onAccessibilityEscape` on Android - the system Back gesture handles "go back" there.
>

A complete working example lives in the SampleApp - see [`useScreenReaderComposerFocusEffect`](https://github.com/GetStream/stream-chat-react-native/blob/develop/examples/SampleApp/src/utils/useScreenReaderComposerFocusEffect.tsx), wired into the channel and thread screens, with `accessibilityRole="header"` on
the shared `ScreenHeader`.

Native headers (i.e ones from `react-navigation`) handle this implicitly and invoke `accessibilityPerformEscape` natively, meaning they should simply just work.

## Detecting Screen Reader and Reduced Motion

Two utility hooks expose the live OS-level state:

```tsx
import {
  useReducedMotionPreference,
  useScreenReaderEnabled,
} from "stream-chat-react-native";

const ConfettiBurst = () => {
  const reduceMotion = useReducedMotionPreference();
  const duration = reduceMotion ? 0 : 1500;
  return /* ...animate using `duration`... */;
};

const RecorderButton = () => {
  const screenReaderOn = useScreenReaderEnabled();
  // Render the tap-mode variant when SR is on, otherwise the hold-to-record gesture.
  return /* ... */;
};
```

>
> **Warning:** Do not call `useScreenReaderEnabled()` inside list-item components rendered by `MessageList`, `ChannelList`, or `ThreadList`. Every screen-reader toggle would re-render every row. Lift the subscription up to the parent screen and pass the boolean down only where it is needed.
>

## Custom Components

When you replace an SDK component via the `WithComponents` override pattern, the SDK no longer wires accessibility for that component — you own it. To mirror the SDK's behavior in a custom component, read the resolved configuration from `useAccessibilityContext()`:

```tsx
import {
  useAccessibilityContext,
  useScreenReaderEnabled,
} from "stream-chat-react-native";

export const CustomAudioRecorder = () => {
  const { audioRecorderTapMode } = useAccessibilityContext();
  const screenReaderOn = useScreenReaderEnabled();

  const useTapMode =
    audioRecorderTapMode === "always" ||
    (audioRecorderTapMode === "auto" && screenReaderOn);

  return useTapMode ? <TapToRecordUI /> : <HoldToRecordUI />;
};
```

This keeps your custom component honest with whatever the integrator passed to `OverlayProvider`.

### Bridging TalkBack double-tap to `onPress`

Some Android `Pressable` configurations don't reliably forward TalkBack's double-tap activation to the existing `onPress` handler. `useAccessibilityActivateAction` returns `accessibilityActions` and an `onAccessibilityAction` handler that bridge the screen-reader `activate` action back to your press callback. The hook returns `undefined` when accessibility is disabled, so you can spread its result unconditionally.

```tsx
import { Pressable } from "react-native";
import { useAccessibilityActivateAction } from "stream-chat-react-native";

const CustomActionButton = ({ accessibilityLabel, onPress }) => {
  const activateAction = useAccessibilityActivateAction({
    onPress,
    shouldHandleActivate: !!onPress && !!accessibilityLabel,
  });

  return (
    <Pressable
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityRole="button"
      onPress={onPress}
      {...activateAction}
    >
      {/* ... */}
    </Pressable>
  );
};
```

The SDK's own `Button` component already wires this hook internally, so you only need it when you build a custom pressable from the React Native primitives.

## Testing

- Set `forceScreenReaderMode: true` while iterating locally to render the accessible variants without enabling VoiceOver or TalkBack on the device.
- Pair with the `Streami18n` override flow above to verify that localized labels reach the screen reader correctly.
- Enable [VoiceOver on iOS](https://support.apple.com/guide/iphone/turn-on-and-practice-voiceover-iph3e2e415f/ios) and [TalkBack on Android](https://support.google.com/accessibility/android/answer/6283677) on a real device before sign-off.
- Run through the same flows with [Reduce Motion](https://support.apple.com/en-us/HT202655) enabled to catch components whose animations should respect the preference.

## Platform Notes

- **iOS / VoiceOver** — focus management uses `AccessibilityInfo.setAccessibilityFocus(reactTag)`. After a modal opens or a layout shift, defer the focus call by one frame (`requestAnimationFrame`) so the accessibility tree has settled.
- **Android / TalkBack** — the SDK uses `AccessibilityInfo.announceForAccessibility` for both platforms because the cross-platform behavior is more predictable than relying on `accessibilityLiveRegion` alone. If you author your own announcement code and target Android specifically, both mechanisms are available to you.
- `setAccessibilityFocus` on Android requires the React tag from `findNodeHandle(ref)`. If your custom component override uses a functional ref, expose a method or pass the ref through.
- **Attachment picker** — when the picker opens, its bottom sheet is treated as a single accessibility group. Focus transitions smoothly from the composer into the picker contents, and the backdrop is excluded from the accessibility tree. No integrator action is required; this is automatic when accessibility is enabled.

If you need to step outside the SDK's accessibility surface — for example to call `setAccessibilityFocus` on your own view, query the boldText or grayscale flags, or add custom announcement behavior — work directly against the React Native [`AccessibilityInfo`](https://reactnative.dev/docs/accessibilityinfo) API. The SDK's hooks (`useScreenReaderEnabled`, `useReducedMotionPreference`, `useAccessibilityAnnouncer`) are thin wrappers and you can mix them with raw `AccessibilityInfo` calls in the same component.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/guides/accessibility/](https://getstream.io/chat/docs/sdk/react-native/guides/accessibility/).