Accessibility
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:
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 |
announceConnectionState |
true |
announces when the connection drops and comes back, see 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 |
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. 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:
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.
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:
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:
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
onAccessibilityEscapeon 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.
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:
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 covers the details.
The SDK's announcer doesn't read 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
CompositeAccessibilityProbemakes decorative content, such as an avatar or a group of icons, one focus stop that readslabel, and hides the content itself from screen readers. Withlabelundefined, it renders the children as they are.HiddenA11yTextrenders 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: aPressable, or aViewwithaccessibleand anaccessibilityRole. On Android, a plainViewwithaccessiblereads only one of its texts.
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.
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:
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, ortruewithforceScreenReaderMode. One app-wide subscription inOverlayProviderbacks it.useReducedMotionPreference()returns whether the user asked to reduce motion. Shorten or skip animations when it'strue.useAccessibilityServiceEnabled()returns whether any Android accessibility service runs, such as Switch Access, not only TalkBack. It'sfalseon 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.accessibilityLabelanda11y.newMessages.withCount.accessibilityLabel: the new message announcements;a11y.connection.offline.accessibilityLabel,a11y.connection.reconnecting.accessibilityLabelanda11y.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:
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 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: truewhile you develop, to see the screen-reader behavior without turning on VoiceOver or TalkBack. - Try every flow with VoiceOver and TalkBack on real devices before a release, and once with reduce motion on.
- On Android,
adb shell uiautomator dumpwrites 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 documents the accessibility prop.