// Simplified view of what `Chat` renders for you.
<AriaLiveAnnouncerProvider>
{/* ...your app... */}
<AriaLiveOutlet portal />
</AriaLiveAnnouncerProvider>Accessibility
The React SDK ships with a shared set of accessibility (a11y) primitives and applies them across the core chat surfaces so that the composer, channel and thread lists, search, context menus, and dialogs are operable and intelligible with a keyboard and a screen reader. The work targets WCAG 2.1.
Most of this is on by default and requires no configuration. This guide explains what you get out of the box, the primitives you can reuse in your own components, and the public props you can use to tune the experience. For practical, copy-paste recipes see the Accessibility cookbook.
What you get out of the box
Rendering the SDK inside Chat enables, with no extra setup:
- Screen-reader announcements for transient events (incoming messages, notifications, a Giphy being sent, a poll being created, search result counts, autocomplete suggestion counts, which channel or thread was opened, audio playback speed changes, and voice-recording lifecycle events).
- Keyboard navigation of the channel list, thread list, search results, autocomplete suggestions, and context menus (roving
listbox/optionsemantics, arrow keys,Home/End). - Focus management for transient flows (dialogs, popovers, the Giphy preview) that captures focus before it moves and restores it afterwards.
- A single, coherent accessible name per channel and thread list row, instead of the screen reader reading every nested element.
The behavior and visuals are unchanged for sighted mouse users.
How announcements are wired up
Announcements use a single aria-live model. Chat mounts an AriaLiveAnnouncerProvider and one AriaLiveOutlet at the root, so any descendant can announce a message and have it spoken. You do not mount these yourself.
The provider owns the announcement state and the announce() API. Outlets are the render targets that paint the visually-hidden live regions. Only one outlet is live at a time (the innermost by stacking layer), which keeps exactly one live region active in the current accessibility-tree scope.
Modal dialogs
Assistive technologies suppress live regions that sit outside an active aria-modal subtree. To keep announcements audible inside a modal, the SDK's modal surfaces render their own AriaLiveOutlet with a higher layer, and the provider automatically routes announcements to it while the modal is open. If you build a custom aria-modal dialog and want announcements made inside it to be spoken, render an outlet inside the modal:
import { AriaLiveOutlet } from "stream-chat-react";
const MyModal = ({ children }) => (
<div role="dialog" aria-modal="true">
{children}
{/* A layer above the root outlet (0) so announcements land inside the modal. */}
<AriaLiveOutlet layer={1} />
</div>
);| Prop | Type | Default | Description |
|---|---|---|---|
layer | number | 0 | Stacking layer. The provider renders into the highest-layer outlet. Outlets inside an aria-modal subtree must use a higher layer than the root. |
portal | boolean | false | When true, render the live region into a document.body portal instead of in place. Use for the root outlet; modal outlets must render in place (omit) so they stay inside the aria-modal subtree. |
Announcing to assistive technology
There are two hooks for announcing, at two levels of abstraction. Both must be called inside Chat.
useInteractionAnnouncements (recommended)
useInteractionAnnouncements is a typed registry of the SDK's built-in interaction events. Each entry maps an intent to a localized message, an aria-live priority, and a delivery policy (immediate, debounced, or provider-delayed). This is the recommended hook because you get localization and sensible delivery for free.
import { useInteractionAnnouncements } from "stream-chat-react";
const MyComponent = () => {
const { announceInteraction, cancelInteraction } =
useInteractionAnnouncements();
// Announce a parameterized interaction.
const onOpen = (channelName: string) =>
announceInteraction("channel.opened", { name: channelName });
// Cancel a pending (debounced, not-yet-spoken) announcement, e.g. when the source is torn down.
const onExitSearch = () => cancelInteraction("search.resultCount");
return null;
};The registry (localized via translation keys, mostly in the aria/* namespace) includes:
| Interaction | Parameters | Priority | Delivery |
|---|---|---|---|
channel.opened | { name } | polite | provider-delayed (~1.5s) |
thread.opened | { name } | polite | provider-delayed (~1.5s) |
command.selected | { command } | polite | provider-delayed (~1.5s) |
user.selected | { user } | polite | immediate |
giphy.sent | none | polite | immediate |
giphy.canceled | none | polite | immediate |
giphy.shuffled | { title? } | polite | immediate |
poll.dialogOpened | none | assertive | immediate (deduped) |
poll.optionPickedUp | { option } | assertive | immediate |
poll.optionDropped | { option, position } | assertive | immediate |
poll.optionRemoved | { option } | polite | immediate |
poll.sent | none | polite | immediate |
search.resultCount | { count, allResultsLoaded? } | polite | debounced (~1s) |
search.cleared | none | polite | immediate |
suggestions.count | { count, suggestionsLabel? } | polite | debounced (~500ms) |
audioPlayer.playbackRateChanged | { rate } | polite | immediate (deduped) |
voiceRecording.started | none | polite | immediate |
voiceRecording.paused | none | polite | immediate |
voiceRecording.resumed | none | polite | immediate |
voiceRecording.attached | none | polite | immediate |
voiceRecording.sent | none | polite | immediate |
Parameterized interactions also accept per-call delivery overrides alongside their params: debounceMs (collapse rapid repeats; cancelled if the call site unmounts) and delayMs (a single deferred emit that survives the caller unmounting). debounceMs wins if both are given.
announceInteraction("search.resultCount", { count: 12, debounceMs: 800 });useAriaLiveAnnouncer (low-level)
For ad-hoc messages that are not in the registry, useAriaLiveAnnouncer returns a bare announce function. You are responsible for the (already-localized) message string.
import { useAriaLiveAnnouncer, useTranslationContext } from "stream-chat-react";
const MyComponent = () => {
const announce = useAriaLiveAnnouncer();
const { t } = useTranslationContext();
const onDone = () => announce(t("Upload complete"), { priority: "polite" });
return null;
};announce(message, options) accepts:
| Option | Type | Description |
|---|---|---|
priority | 'polite' | 'assertive' | aria-live priority. Defaults to 'polite'. Use 'assertive' only for messages that must interrupt. |
delayMs | number | Delay before the message enters the live region, so it lands after a competing focus/typing read-out. |
dedupeMs | number | Announce an identical message at most once per window. Guards against duplicates (e.g. StrictMode double-invoke). |
announce returns a cancel function. For a delayed announcement, call it from effect cleanup so a closing surface does not announce after it unmounts. For an immediate announcement it is a no-op.
Focus and visibility utilities
useFocusReturn
A generic capture-before-steal, restore-on-finish focus primitive, the pattern dialogs and popovers use. Call reserve() before you move focus into a transient surface, then restore() when it completes to send focus back to where it was, without your surface needing to know which element that is.
import { useFocusReturn } from "stream-chat-react";
const GiphyPreview = () => {
const { reserve, restore } = useFocusReturn();
const open = () => {
reserve(); // captures document.activeElement (e.g. the composer)
// ...move focus into the preview...
};
const finish = () => {
// ...send / cancel...
restore(); // focus returns to the composer
};
return null;
};reserve(options?) captures the current document.activeElement by default; pass { target } to override. restore() moves focus back if the element is still connected and clears the reservation. Focus is moved synchronously, so defer any announcement yourself if you want the focus read-out to be spoken first.
useInertWhenHidden
The canonical way to remove a still-mounted-but-hidden control from the tab order and the accessibility tree. It returns a props object to spread onto the element. When hidden is true the element becomes aria-hidden, inert, tabindex="-1" (and hidden); when false, every attribute is omitted so spreading is a no-op.
import { useInertWhenHidden } from "stream-chat-react";
const Toolbar = ({ collapsed }: { collapsed: boolean }) => {
const inertProps = useInertWhenHidden(collapsed);
return <button {...inertProps}>Add attachment</button>;
};For an element hidden by a CSS transition (opacity/scale), pass { setHiddenAttribute: false } so the hidden attribute (which forces display: none and would kill the transition) is omitted while the a11y/focus semantics still apply.
const inertProps = useInertWhenHidden(collapsed, { setHiddenAttribute: false });Prefer not rendering a control at all when it is unavailable. Reach for useInertWhenHidden only when the element must stay mounted, for example to preserve internal state, layout measurements, or an enter/exit transition.
The hook selects the warning-free form of the inert attribute for React 17/18 vs 19 automatically.
Incoming message announcements
Incoming messages are announced with useIncomingMessageAnnouncements, which reacts to the message.new event on the active channel. It announces new messages from other users (batched and throttled), and skips your own messages as well as deleted, ephemeral, system, and error messages. It is driven purely by chat events, not by the notification system.
The SDK wires this up for you, but the hook is exported so you can reuse it in a custom surface:
import { useIncomingMessageAnnouncements } from "stream-chat-react";
const IncomingMessageAnnouncer = ({ channel, ownUserId }) => {
useIncomingMessageAnnouncements({ channel, ownUserId });
return null;
};Notification announcements
Notifications are short-lived or persistent app messages that your code (or the SDK) explicitly adds to the notification system through the functions returned by useNotificationApi: addNotification and addSystemNotification.
import { useNotificationApi } from "stream-chat-react";
const SaveButton = () => {
const { addNotification } = useNotificationApi();
const onSave = async () => {
await save();
// Registering a notification here is what makes it announceable.
addNotification({ message: "Settings saved", severity: "success" });
};
return <button onClick={onSave}>Save</button>;
};NotificationAnnouncer reacts to those registered notifications (not to message.new) and announces each new one. Error-severity notifications are announced assertive; all others polite. Override it to filter which notifications are announced and to reshape the spoken text:
import { Chat, NotificationAnnouncer } from "stream-chat-react";
const CustomNotificationAnnouncer = () => (
<NotificationAnnouncer
notificationFilter={(notification) => notification.severity !== "info"}
buildNotificationAnnouncement={({
defaultMessage,
notification,
translatedMessage,
}) =>
notification.severity === "error"
? `Error: ${translatedMessage}`
: defaultMessage
}
/>
);
// Override via ComponentContext:
<Chat client={client} /* ... */>
{/* provide { NotificationAnnouncer: CustomNotificationAnnouncer } through ComponentContext */}
</Chat>;See the Custom announcements recipe for a fuller notification example.
Customizing accessible names for list rows
Each channel and thread list row exposes a single composed accessible name (rather than the screen reader walking every nested element). The name is assembled from named parts in a defined reading order, and both are configurable through the accessibleLabelConfig prop on ChannelListItemUI and ThreadListItemUI.
The default channel row order is: name, active, unread count, last message (sender + preview), attachments, link preview, delivery status, time. The default thread row order is: channel name, active, unread count, parent message, reply count, last activity.
You customize the label by supplying your own presentation component through ComponentContext and forwarding the config:
import {
ChannelListItemUI as DefaultChannelListItemUI,
DEFAULT_CHANNEL_LIST_ITEM_LABEL_ORDER,
type ChannelListItemLabelConfig,
} from "stream-chat-react";
const accessibleLabelConfig: ChannelListItemLabelConfig = {
// `parts` is merged over the defaults: a new key is added, a same-named key overrides.
parts: {
pinned: ({ channel, t }) =>
channel.state.membership?.pinned_at ? t("Pinned") : undefined,
muted: ({ channel, t }) =>
channel.muteStatus().muted ? t("Muted") : undefined,
},
// `order` decides which parts render AND their sequence. A part not listed here is never rendered.
order: [
"name",
"pinned",
"muted",
...DEFAULT_CHANNEL_LIST_ITEM_LABEL_ORDER.slice(1),
],
};
const MyChannelPreview = (props) => (
<DefaultChannelListItemUI
{...props}
accessibleLabelConfig={accessibleLabelConfig}
/>
);
// <ComponentProvider value={{ ChannelListItemUI: MyChannelPreview }}> ... </ComponentProvider>accessibleLabelConfig accepts:
| Field | Type | Description |
|---|---|---|
build | (data) => string | Full override: return the entire label. Ignores order/parts/separator. |
parts | Record<string, (data) => string | undefined> | Merged over the default parts. Return undefined/'' to omit a segment. |
order | ReadonlyArray<string> | Which parts to include, in order. A part not listed is never rendered. |
separator | string | Joiner between non-empty parts. Defaults to '. '. |
The same shape applies to ThreadListItemUI via ThreadListItemLabelConfig and DEFAULT_THREAD_LIST_ITEM_LABEL_ORDER. Each part receives the row's data (channel/thread state, active, unreadCount, the translate function t, and so on). See the Accessible list labels recipe for a complete example.
Component props for accessibility
A few components expose additive, non-breaking props for accessibility tuning.
Search inputProps
Search accepts inputProps, spread onto the underlying <input>. Use it to opt the field out of password managers or add extra attributes. The component's own controlled props (value, onChange, onBlur, type, id, disabled, ref) take precedence; className is merged.
import { Search } from "stream-chat-react";
<Search
inputProps={{
autoComplete: "off",
"data-1p-ignore": true,
"data-lpignore": true,
}}
/>;ContextMenu initialFocus
Context menu submenus move focus into themselves when opened so it is not lost when the parent item unmounts. The initialFocus option controls where focus lands:
| Value | Behavior |
|---|---|
'first-item' | Skip the header (e.g. the back button) and focus the first content item. This is the default. |
'first' | Focus the first focusable element, including the header. |
'none' | Do not move focus on open. |
Skip navigation
SkipNavigation is an opt-in skip-links helper for keyboard users. It is not rendered by Chat. Render it as the first focusable element in your layout and pass the DOM ids of the targets, ordered by keyboard priority.
import { SkipNavigation } from "stream-chat-react";
<SkipNavigation
targetIds={["my-message-composer-textarea", "my-channel-root"]}
getLinkLabel={(targetId) =>
targetId === "my-message-composer-textarea"
? "Skip to message composer"
: "Skip to channel messages"
}
/>;Each target id must be a DOM id (without #). The links keep native hash navigation and also move focus programmatically; if a target is not naturally focusable, tabindex="-1" is applied temporarily and removed on blur. See the Skip navigation recipe for a view-aware setup.
| Prop | Type | Description |
|---|---|---|
targetIds | string[] | Required. Ordered DOM ids that receive focus on activation. |
getLinkLabel | (targetId, index) => ReactNode | Optional per-link label. Defaults to Skip to {targetId}. |
| ...anchorProps | ComponentPropsWithoutRef<'a'> (minus href) | Spread onto each link (className, onClick, and so on). |
Localizing announcements
All built-in announcements are localized through the aria/* translation namespace. The SDK ships these keys across all 12 bundled locales. Because they are natural-language strings, non-English translations are best-effort and worth reviewing.
To change the wording, override the aria/* keys the same way you override any other translation. For example:
import { Streami18n } from "stream-chat-react";
const i18n = new Streami18n({
language: "en",
translationsForLanguage: {
"aria/Opened channel: {{ name }}": "Now viewing {{ name }}",
"aria/{{ count }} unread message_other": "{{ count }} unread",
},
});See the Localization guide for how to register overrides and add languages.
Exports reference
All symbols are importable from stream-chat-react.
| Symbol | Kind | Purpose |
|---|---|---|
AriaLiveAnnouncerProvider | component | Owns announcement state and the announce() API (mounted by Chat). |
AriaLiveOutlet | component | Renders the live regions; mount inside custom modals. |
useAriaLiveAnnouncer | hook | Low-level announce(message, options) function. |
useInteractionAnnouncements | hook | Typed registry of built-in interaction announcements. |
useFocusReturn | hook | Capture and restore focus around transient flows. |
useInertWhenHidden | hook | Props to remove a hidden-but-mounted control from tab/a11y tree. |
useIncomingMessageAnnouncements | hook | Announce new incoming messages on a channel. |
NotificationAnnouncer | component | Announce app notifications (filterable, reshapeable). |
SkipNavigation | component | Opt-in skip links. |
composeChannelListItemAccessibleLabel | function | Build a channel row's accessible name from parts. |
composeThreadListItemAccessibleLabel | function | Build a thread row's accessible name from parts. |
defaultChannelListItemLabelParts | const | Default channel row label parts (reuse when overriding). |
defaultThreadListItemLabelParts | const | Default thread row label parts. |
DEFAULT_CHANNEL_LIST_ITEM_LABEL_ORDER | const | Default channel row part order. |
DEFAULT_THREAD_LIST_ITEM_LABEL_ORDER | const | Default thread row part order. |
ChannelListItemLabelConfig | type | Config accepted by ChannelListItemUI's accessibleLabelConfig. |
ThreadListItemLabelConfig | type | Config accepted by ThreadListItemUI's accessibleLabelConfig. |
- What you get out of the box
- How announcements are wired up
- Announcing to assistive technology
- Focus and visibility utilities
- Incoming message announcements
- Notification announcements
- Customizing accessible names for list rows
- Component props for accessibility
- Skip navigation
- Localizing announcements
- Exports reference