# 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](/chat/docs/sdk/react/guides/customization/accessibility/accessible-list-labels/).

## What you get out of the box

Rendering the SDK inside [`Chat`](/chat/docs/sdk/react/components/core-components/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`/`option` semantics, 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.

```tsx
// Simplified view of what `Chat` renders for you.
<AriaLiveAnnouncerProvider>
  {/* ...your app... */}
  <AriaLiveOutlet portal />
</AriaLiveAnnouncerProvider>
```

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:

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
const inertProps = useInertWhenHidden(collapsed, { setHiddenAttribute: false });
```

<Admonition type="note">

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.

</Admonition>

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:

```tsx
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`](/chat/docs/sdk/react/guides/notifications/): `addNotification` and `addSystemNotification`.

```tsx
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:

```tsx
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](/chat/docs/sdk/react/guides/customization/accessibility/custom-announcements/) 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:

```tsx
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](/chat/docs/sdk/react/guides/customization/accessibility/accessible-list-labels/) 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.

```tsx
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.

```tsx
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](/chat/docs/sdk/react/guides/customization/accessibility/skip-navigation/) 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:

```tsx
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](/chat/docs/sdk/react/guides/theming/translations/) 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`.      |


---

This page was last updated at 2026-07-09T16:01:12.831Z.

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