Accessibility (a11y) — stream-chat-react-native
This 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 via a single imperative announcer; and modal/sheet surfaces apply the platform-correct focus-trap props.
Opt-in default
A11y is off by default. Existing integrators see no behavior change. To enable:
import { Chat, OverlayProvider } from 'stream-chat-react-native';
<OverlayProvider accessibility={{ enabled: true }}>
<Chat client={client}>
{/* ... */}
</Chat>
</OverlayProvider>
When enabled is false:
- No announcer context mounts;
useAccessibilityAnnouncer()returns a noop. <NotificationAnnouncer />exits without announcements.useIncomingMessageAnnouncementsdoes not subscribe tochannel.on('message.new').- No
AccessibilityInfoevent listeners attach. - Components still render their
accessibilityRole/accessibilityState/ etc. attributes (these are passed to native views and only consulted by VO/TalkBack when active — sighted users incur ~zero cost). useA11yLabel(key, params)returnsundefinedsot('a11y/...')is not called on hot list paths.
Configuration shape
type A11yMode = 'auto' | 'always' | 'never';
type AccessibilityConfig = {
enabled?: boolean; // default false — must opt in
forceScreenReaderMode?: boolean; // default false — testing aid
announceNewMessages?: boolean; // default true (when enabled)
announceTypingIndicator?: boolean; // default false (noisy)
announceConnectionState?: boolean; // default true
audioRecorderTapMode?: A11yMode; // default 'auto'
imageGalleryScreenReaderMode?: A11yMode; // default 'auto'
messageActionsTrigger?: 'long-press' | 'auto' | 'always-button'; // default 'auto'
};
For RN-specific gesture-alternative toggles, the enum semantics are:
auto— swap UI when a screen reader is detected viaAccessibilityInfo.always— show the accessible variant for everyone (sighted + SR users).never— SDK never swaps UI; the integrator handles it (e.g. when shipping a custom component viaWithComponents).
Localization
All a11y strings flow through the existing Streami18n translation pipeline under the a11y/* namespace. Defaults ship in English in every locale; integrators can override per-key via the same mechanism they use for other strings:
const i18n = new Streami18n('nl');
i18n.registerTranslation('nl', {
'a11y/Avatar of {{name}}': 'Avatar van {{name}}',
'a11y/{{count}} new messages': '{{count}} nieuwe berichten',
});
<OverlayProvider accessibility={{ enabled: true }} i18nInstance={i18n}>
<Chat client={client} i18nInstance={i18n}>
{/* ... */}
</Chat>
</OverlayProvider>
validate-translations (run as part of yarn lint) enforces non-empty values for every a11y/* key in every locale.
SDK-owned Button components can translate their own accessible names from an i18n key:
<Button accessibilityLabelKey='a11y/Send message' iconOnly {...buttonProps} />
Use accessibilityLabelParams for interpolated labels. SDK-owned buttons should pass the key/params only. When migrating an already-released button, keep the translation value aligned with the existing label unless the label change is intentionally breaking.
Public hooks and components
Importable from stream-chat-react-native:
useAccessibilityContext()— read the resolved config.useAccessibilityAnnouncer()—(message, priority?) => voidimperative announcer; mirrorsuseAriaLiveAnnouncerfromstream-chat-react.useScreenReaderEnabled()— live boolean fromAccessibilityInfo.screenReaderChanged.useReducedMotionPreference()— live boolean fromAccessibilityInfo.reduceMotionChanged.useResolvedModalAccessibilityProps()— returns{ accessibilityViewIsModal, importantForAccessibility }for the active platform.useA11yLabel(key, params)— translated label orundefinedwhen disabled.useAnnounceOnStateChange(message, options)— debounced live-region helper that announces on message change and dedupes consecutive identical strings (good for state-driven labels like loading/error transitions).useAnnounceOnShow(visible, message, { delayMs?, priority? })— announces on eachvisible: false → truetransition and resets on hide, so re-shows re-announce. Pair withuseA11yLabel(...)for the message. Used byBottomSheetModalandAutoCompleteSuggestionList.useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })— throttled, batched announcement of new messages.<NotificationAnnouncer />— connection-state announcer (mounted by<Channel>).
Cross-SDK parity
API shapes mirror stream-chat-react#3146 wherever the platforms agree (useAccessibilityAnnouncer ≈ useAriaLiveAnnouncer, useIncomingMessageAnnouncements ≈ identical params and throttle semantics, a11y/* i18n namespace shared). Mobile-only deviations:
<OverlayProvider accessibility={...}>config object — RN needs gesture-alternative toggles (audio hold-to-record, gallery pinch/pan) that don't exist on web.- No
<VisuallyHidden>, no<SkipNavigation>, no roving-focus utilities — RN announcer is imperative, mobile has no Tab key. useResolvedModalAccessibilityPropsreturnsaccessibilityViewIsModal(iOS) +importantForAccessibility='yes'(Android) instead ofaria-modal.- Live regions:
AccessibilityInfo.announceForAccessibilitycross-platform; AndroidaccessibilityLiveRegion='polite'on hidden Views as a backup where useful.
Platform-specific notes
- iOS / VoiceOver: focus management uses
AccessibilityInfo.setAccessibilityFocus(reactTag). After modal open or layout shift, defer the focus call by one frame (requestAnimationFrame) so the a11y tree has settled. - Android / TalkBack: prefer
accessibilityLiveRegionover imperative announcements where both are an option — TalkBack interrupts more aggressively when announcing imperatively. The announcer usesannounceForAccessibilityfor both platforms because behavior is closer to predictable across devices. setAccessibilityFocuson Android requires the React tag to come fromfindNodeHandle(ref); if your custom component override uses a functional ref, expose a method or passrefthrough.
Out of scope (current state)
- AudioRecorder gesture-alternative tap mode (planned; gated on
audioRecorderTapMode). - ImageGallery screen-reader-mode UI swap (planned; gated on
imageGalleryScreenReaderMode). - Reassure performance benchmark (planned for the lint/perf commit).
- ESLint
react-native-a11yrule plugin (deferred — adds a runtime dependency).