# Accessibility

> **Info:** This is release candidate documentation for **Stream Chat React Native SDK v10**. For the latest stable version, see the [latest version (v9)](https://getstream.io/chat/docs/sdk/react-native/).

The SDK's components work with VoiceOver on iOS and TalkBack on Android: they carry accessible names, announce what happens out of sight, and keep focus inside the menus and sheets they open. Accessibility is opt-in, and turned on in `OverlayProvider`. This page covers turning it on, what users get, the parts you can reuse in your own components, and how to change what screen readers hear.

## Turning it on

Pass an `accessibility` config to `OverlayProvider`, not to `Chat`:

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

// Declared at module scope, so the provider gets the same object on every render.
const accessibility: AccessibilityConfig = { enabled: true };

export const ChatRoot = ({
  children,
  client,
}: PropsWithChildren<{ client: StreamChat }>) => (
  <OverlayProvider accessibility={accessibility}>
    <Chat client={client}>{children}</Chat>
  </OverlayProvider>
);
```

Every other option applies only while `enabled` is `true`:

| Option                               | Default | What it does                                                                                                                            |
| ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`                            | `false` | turns the accessibility layer on                                                                                                        |
| `announceNewMessages`                | `true`  | announces new messages from other users, see [Incoming message announcements](#incoming-message-announcements)                          |
| `announceConnectionState`            | `true`  | announces when the connection drops and comes back, see [Connection announcements](#connection-announcements)                           |
| `announceTypingIndicator`            | `false` | announces the AI typing indicator's "Thinking..." and "Generating..."                                                                   |
| `forceScreenReaderMode`              | `false` | behaves as if a screen reader were running, for testing without VoiceOver or TalkBack                                                   |
| `hasInteractiveAccessibilityContent` |         | decides which messages let the screen reader reach their parts, see [Messages with interactive parts](#messages-with-interactive-parts) |

`OverlayProvider` also accepts `audioRecorderTapMode`, `imageGalleryScreenReaderMode` and `messageActionsTrigger`, but no component reads them, so they change nothing.

With `enabled` off, no announcer mounts and no accessibility listener is added. Most accessible names, hints and custom actions are left out, which also skips their translation lookups. Roles and states, such as `accessibilityRole="button"` and `accessibilityState={{ disabled }}`, render either way.

## What you get out of the box

With accessibility on:

- **Accessible names.** Buttons, avatars, channel rows, messages, reactions, attachments, polls, the composer and the channel details screens have names and values a screen reader reads, such as "Scroll to bottom, 3 new messages". A message reads its sender and its text as one focus stop.
- **Announcements.** Screen readers hear new messages from other users, connection changes, the suggestion list appearing ("Mention suggestions available"), a reply or an edit starting ("Replying to Bob"), and a bottom sheet opening.
- **Gestures with an alternative.** A message's long-press menu has the hint "Double tap and hold to activate contextual menu". While the scroll-to-bottom button shows, the message list has a "Scroll to bottom" custom action. The image gallery is an adjustable element that reads "2 of 5", and moves between images with the increment and decrement gestures.
- **Focus.** When the composer mounts, the screen reader's focus moves to its input. That sticks when Android opens a screen; for the other cases, see [Focusing the composer when a screen opens](#focusing-the-composer-when-a-screen-opens). Replying to a message focuses the input. The message menu, the image gallery and the SDK's bottom sheets keep focus inside them while open, and the gallery gives focus back to the image that opened it when it closes.
- **Android activation.** SDK buttons map TalkBack's double tap to their press handler, which some Android pressables don't do by themselves.

Nothing changes for sighted users.

## How announcements are wired up

`OverlayProvider` renders the accessibility provider. With `enabled` on, it mounts one announcer, and every component inside it that announces goes through it. The announcer calls React Native's `AccessibilityInfo.announceForAccessibility()` on both platforms.

An announcement waits 50 ms. A newer one with the same priority, made in that time, replaces it, so a burst of changes is read once. The priority, `'polite'` or `'assertive'`, keeps the two kinds from replacing each other; both are read with the same call.

Outside the provider, or with `enabled` off, announcing does nothing, so components that announce still work.

## Announcing to assistive technology

Three hooks announce. All three do nothing while accessibility is off.

### Announcing a custom event

`useAccessibilityAnnouncer()` returns `announce(message, priority?)`, for a message you write and translate yourself. An export button tells screen-reader users when the export is done:

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

export const ExportButton = ({
  exportConversation,
}: {
  exportConversation: () => Promise<void>;
}) => {
  const announce = useAccessibilityAnnouncer();

  const onExport = async () => {
    await exportConversation();
    announce("Conversation exported");
  };

  return (
    <Pressable accessibilityRole="button" onPress={onExport}>
      <Text>Export</Text>
    </Pressable>
  );
};
```

The priority defaults to `'polite'`. The message is spoken as you pass it, so translate it first, as in [Localizing announcements](#localizing-announcements).

### Announcing a state change

`useAnnounceOnStateChange(message, { debounceMs?, priority? })` announces `message` each time it changes to a new value that isn't empty. It waits `debounceMs`, 250 by default, so a value that changes quickly is read once, and it skips a value equal to the last one it read:

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

type UploadStatus = "idle" | "uploading" | "done" | "failed";

const messages: Record<UploadStatus, string | null> = {
  done: "Upload complete",
  failed: "Upload failed",
  idle: null,
  uploading: "Uploading",
};

export const UploadStatusAnnouncer = ({ status }: { status: UploadStatus }) => {
  useAnnounceOnStateChange(messages[status]);
  return null;
};
```

The AI typing indicator uses it.

### Announcing a surface when it shows

`useAnnounceOnShow(visible, message, { delayMs?, priority? })` announces `message` each time `visible` turns `true`, after `delayMs`, 500 by default, so an entrance animation can finish first. Hiding the surface arms it again, so it announces the same message on every show. The suggestion list, the reply preview and the bottom sheets use it.

## Focus utilities

### useSetAccessibilityFocus

`useSetAccessibilityFocus()` returns a stable function that moves the screen reader's focus onto a ref or a native node handle, on the next frame. It only moves the focus. It opens no keyboard and doesn't press anything. It does nothing unless a screen reader is running.

`useScreenReaderMountFocus(ref)` does the same when the component mounts, and again once the screen reader's state is known. `MessageComposer` uses it on its input.

### Focusing the composer when a screen opens

Focus set on mount sticks only when Android opens a screen. On iOS, and on both platforms when the user goes back to a screen, the system moves focus itself when the screen transition ends, and replaces a focus set before. So set it after the transition, in your navigator. With React Navigation's native stack, `transitionEnd` fires when the transition ends. `Channel` passes the composer's input to `setInputRef`:

```tsx title="ChannelScreen.tsx"
import { useCallback, useRef } from "react";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import type { ParamListBase } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  useSetAccessibilityFocus,
} from "stream-chat-react-native";
import type { TextInputRef } from "stream-chat-react-native";

export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => {
  const inputRef = useRef<TextInputRef | null>(null);
  const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>();
  const setAccessibilityFocus = useSetAccessibilityFocus();

  // Stable: an inline function would detach and attach the ref on every render.
  const setInputRef = useCallback((ref: TextInputRef | null) => {
    inputRef.current = ref;
  }, []);

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

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

The `SampleApp` does this in its `useScreenReaderComposerFocusEffect` hook, for the channel and the thread screens.

### Keeping the header reachable

With focus on the composer, keep the screen's header easy to reach:

- **Mark the title as a heading** with `accessibilityRole="header"`. VoiceOver users reach it with the Headings rotor, and TalkBack users with the Headings reading control.
- **Handle VoiceOver's escape gesture** on a header of your own: put `onAccessibilityEscape` on the screen's root view, and go back in it. Android's back gesture already goes through your navigator, and native-stack headers handle the escape gesture themselves.

>
> **Warning:** On the New Architecture, a `View` that only lays out its children has no native view, so `onAccessibilityEscape` on it never fires. Add `collapsable={false}` to the view that has the handler.
>

## Incoming message announcements

`MessageList` announces new messages from other users through `useIncomingMessageAnnouncements`:

- One message reads "New message from Bob". Several within a second read "3 new messages": the hook speaks at most once a second.
- It skips the user's own messages, and deleted, ephemeral, error, system, failed and still-sending ones.
- A channel's list announces the channel's messages, not replies. A thread's list announces the replies in that thread.

It listens for `message.new` on the channel, and only while `enabled` and `announceNewMessages` are on. `MessageFlashList` doesn't call it, so with `MessageFlashList`, or a message list of your own, render the hook next to the list:

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

// Render inside Channel or Thread, next to the list.
export const IncomingMessageAnnouncer = () => {
  const { channel } = useChannelContext();
  const { thread } = useThreadContext();
  const { client } = useChatContext();

  useIncomingMessageAnnouncements({
    activeThreadId: thread?.id,
    channel,
    ownUserId: client.userId,
  });
  return null;
};
```

Inside a `Thread`, `activeThreadId` limits it to that thread's replies. Without it, the hook announces the channel's messages that aren't replies.

## Connection announcements

`Channel` and `Thread` render a `NotificationAnnouncer`, which announces when the socket's health changes: "Offline" or "Reconnecting" when it goes down, and "Connected" when it comes back. A drop shorter than `offlineNotificationDisplayDelayMs`, five seconds by default, isn't announced. It announces only while `enabled` and `announceConnectionState` are on. [Connection status](https://getstream.io/chat/docs/sdk/react-native/v10/features/app-experience/connection-status/#what-screen-readers-hear) covers the details.

The SDK's announcer doesn't read [notifications](https://getstream.io/chat/docs/sdk/react-native/v10/features/app-experience/notifications/). The default `Notification` is an Android live region, assertive for errors and polite for the rest, so TalkBack reads it, whether accessibility is on or not. To have notifications read on iOS too, announce them yourself with `useAccessibilityAnnouncer()`.

## Building accessible components

A component you pass through `WithComponents` replaces the SDK's one with its accessibility, so it's yours to label. These parts help.

### Accessible names

`useA11yLabel(key, params?)` translates `key` with `params`, and returns `undefined` while accessibility is off, so a list row doesn't call `t()` for nothing. Pass the result to `accessibilityLabel`. It passes no English default, so register an app key of your own in your dictionary, or the label reads the key.

The SDK's `Button` takes `accessibilityLabelKey` and `accessibilityLabelParams` and translates them the same way. Its `accessibilityLabel` prop is used when there is no key.

`composeAccessibilityLabel(...parts)` joins the parts that aren't empty with ", ", so the screen reader pauses between them.

### One focus stop for visual content

- `CompositeAccessibilityProbe` makes decorative content, such as an avatar or a group of icons, one focus stop that reads `label`, and hides the content itself from screen readers. With `label` `undefined`, it renders the children as they are.
- `HiddenA11yText` renders text nobody sees, to add something to what a parent reads, such as "you reacted" on a reaction. It needs a parent that reads its children as one: a `Pressable`, or a `View` with `accessible` and an `accessibilityRole`. On Android, a plain `View` with `accessible` reads only one of its texts.

```tsx
import { Pressable, Text } from "react-native";
import { HiddenA11yText, useA11yLabel } from "stream-chat-react-native";

export const StarButton = ({
  onPress,
  starred,
}: {
  onPress: () => void;
  starred: boolean;
}) => {
  const starredLabel = useA11yLabel("myApp.star.starred.accessibilityLabel");

  return (
    <Pressable accessibilityRole="button" onPress={onPress}>
      <Text>Star</Text>
      {starred ? <HiddenA11yText label={starredLabel} /> : null}
    </Pressable>
  );
};
```

### TalkBack double tap

Some Android pressables don't call `onPress` when TalkBack activates them. `useAccessibilityActivateAction({ onPress, shouldHandleActivate })` returns the `accessibilityActions` and `onAccessibilityAction` props that route TalkBack's activation to `onPress`, or `undefined` while accessibility is off or `shouldHandleActivate` is `false`. The SDK's `Button` already uses it.

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

export const ActionButton = ({
  label,
  onPress,
}: {
  label: string;
  onPress: () => void;
}) => {
  const activateAction = useAccessibilityActivateAction({
    onPress,
    shouldHandleActivate: true,
  });

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

### Modals and sheets

`useResolvedModalAccessibilityProps()` returns the props for the root of a modal surface: `accessibilityViewIsModal` on iOS, which keeps VoiceOver inside it, and `importantForAccessibility="yes"` on Android. With accessibility off it returns an empty object. On Android, also set `importantForAccessibility="no-hide-descendants"` on the content behind the surface while it's open.

### Messages with interactive parts

A message is one focus stop that reads its sender and text. A message with a poll, a quoted message, attachments or a shared location isn't. The screen reader moves through its parts, so each can be pressed. `hasInteractiveAccessibilityContent` decides which messages work the second way. It receives the message and the SDK's answer, so it can add cases:

```ts
import type { AccessibilityConfig } from "stream-chat-react-native";

export const accessibility: AccessibilityConfig = {
  enabled: true,
  // Messages with a product card attachment of your own have a button inside.
  hasInteractiveAccessibilityContent: (message, defaultValue) =>
    defaultValue ||
    !!message.attachments?.some((attachment) => attachment.type === "product"),
};
```

Define the function at module scope, so it stays the same. A new function re-renders every message.

### Screen reader, reduced motion and accessibility services

- `useScreenReaderEnabled()` returns whether VoiceOver or TalkBack is running, or `true` with `forceScreenReaderMode`. One app-wide subscription in `OverlayProvider` backs it.
- `useReducedMotionPreference()` returns whether the user asked to reduce motion. Shorten or skip animations when it's `true`.
- `useAccessibilityServiceEnabled()` returns whether any Android accessibility service runs, such as Switch Access, not only TalkBack. It's `false` on iOS.

All three return `false` while accessibility is off. Read them in a screen rather than in each list row. A row that reads one re-renders when the value changes, and so does every other row.

## Localizing announcements

Accessible names and announcements are translated like the rest of the UI. An accessible name is the `accessibilityLabel` key of the component that shows it, such as `avatar.accessibilityLabel`, and the announcements that belong to no component are in the `a11y.*` namespace:

- `a11y.newMessage.withUser.accessibilityLabel` and `a11y.newMessages.withCount.accessibilityLabel`: the new message announcements;
- `a11y.connection.offline.accessibilityLabel`, `a11y.connection.reconnecting.accessibilityLabel` and `a11y.connection.connected.accessibilityLabel`: the connection announcements.

The SDK ships English only. To change the English wording, pass the keys to `Streami18n`, and give the instance to `OverlayProvider` and `Chat`:

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

const i18nInstance = new Streami18n({
  translationsForLanguage: {
    "a11y.newMessage.withUser.accessibilityLabel": "{{user}} wrote",
    "messageList.scrollToBottom.accessibilityLabel":
      "Go to the latest messages",
  },
});

// Your own keys, such as the one `StarButton` above reads.
const appKeys: LooseTranslationDictionary = {
  "myApp.star.starred.accessibilityLabel": "starred",
};
i18nInstance.registerTranslation("en", appKeys);

const accessibility = { enabled: true };

export const ChatRoot = ({
  children,
  client,
}: PropsWithChildren<{ client: StreamChat }>) => (
  <OverlayProvider accessibility={accessibility} i18nInstance={i18nInstance}>
    <Chat client={client} i18nInstance={i18nInstance}>
      {children}
    </Chat>
  </OverlayProvider>
);
```

A key that takes a count has a `_one` and an `_other` form. For another language, register a dictionary with these keys, as [Localization](https://getstream.io/chat/docs/sdk/react-native/v10/ui/customizing/localization/#registering-dictionaries) explains.

Some accessible names aren't translated, such as "Image Gallery" and "Grid Image" in the image gallery, and "Play Pause Button" on an audio attachment.

## Testing

- Set `forceScreenReaderMode: true` while you develop, to see the screen-reader behavior without turning on VoiceOver or TalkBack.
- Try every flow with [VoiceOver](https://support.apple.com/guide/iphone/turn-on-and-practice-voiceover-iph3e2e415f/ios) and [TalkBack](https://support.google.com/accessibility/android/answer/6283677) on real devices before a release, and once with reduce motion on.
- On Android, `adb shell uiautomator dump` writes the accessibility tree to a file, with each element's bounds and the name TalkBack reads. An element TalkBack can't reach often has empty bounds there.

## Exports reference

Everything is exported from `stream-chat-react-native` and `stream-chat-expo`.

| Export                               | Kind      | What it's for                                                           |
| ------------------------------------ | --------- | ----------------------------------------------------------------------- |
| `AccessibilityConfig`                | type      | the `accessibility` prop of `OverlayProvider`                           |
| `useAccessibilityContext`            | hook      | the resolved config, with every default filled in                       |
| `useAccessibilityAnnouncer`          | hook      | returns `announce(message, priority?)`                                  |
| `useAnnounceOnStateChange`           | hook      | announces a message each time it changes                                |
| `useAnnounceOnShow`                  | hook      | announces a message each time a surface shows                           |
| `useIncomingMessageAnnouncements`    | hook      | announces new messages in a channel or thread                           |
| `NotificationAnnouncer`              | component | announces connection changes; `Channel` and `Thread` render it          |
| `useSetAccessibilityFocus`           | hook      | moves the screen reader's focus onto a ref                              |
| `useScreenReaderMountFocus`          | hook      | moves the screen reader's focus onto a ref on mount                     |
| `useA11yLabel`                       | hook      | a translated accessible name, or `undefined` while accessibility is off |
| `composeAccessibilityLabel`          | function  | joins label parts                                                       |
| `formatAccessibilityValue`           | function  | builds an `accessibilityValue` for a progress or seek bar               |
| `mergeAccessibilityActions`          | function  | merges `accessibilityActions` lists by name                             |
| `CompositeAccessibilityProbe`        | component | one focus stop for decorative content                                   |
| `HiddenA11yText`                     | component | text for screen readers only                                            |
| `useAccessibilityActivateAction`     | hook      | routes TalkBack's activation to `onPress`                               |
| `useResolvedModalAccessibilityProps` | hook      | props for the root of a modal surface                                   |
| `useScreenReaderEnabled`             | hook      | whether a screen reader runs                                            |
| `useReducedMotionPreference`         | hook      | whether the user asked to reduce motion                                 |
| `useAccessibilityServiceEnabled`     | hook      | whether an Android accessibility service runs                           |

[OverlayProvider](https://getstream.io/chat/docs/sdk/react-native/v10/component-reference/chat/overlayprovider/) documents the `accessibility` prop.

---

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