name: accessibility description: Maintain VoiceOver/TalkBack-focused accessibility in stream-chat-react-native. Use when changing interactive components, gestures, modals, lists, media controls, notifications, focus behavior, or live announcements.
Accessibility Maintenance (stream-chat-react-native)
Use this skill whenever code changes can affect screen-reader users (VoiceOver on iOS, TalkBack on Android), gesture-driven flows, focus behavior, motion preferences, or semantic React Native accessibility props.
Non-negotiable rules
- Native semantics first. Use
Pressable,TextInput,Switch,Imagedirectly. UseaccessibilityRoleonly when native semantics cannot represent the widget (menu,menuitem,progressbar,radio,checkbox,article,alert,tablist,tab). Platform caveat:'menu'and'menuitem'are honored by iOS VoiceOver but Android TalkBack silently ignores them (noUIAccessibilityTraitsequivalent). For interactive items that must be announceable on both platforms, use'button'on the leafPressable; the'menu'role can stay on the container as an iOS hint. iOS-supported roles that survive to VoiceOver:button,link,search,image,keyboardkey,text,adjustable,imagebutton,header,summary,none. - Never hardcode English in
accessibilityLabel/accessibilityHint/announcement strings. For SDKButton, passaccessibilityLabelKey='a11y/...'(andaccessibilityLabelParamswhen needed). For non-Button components, useuseA11yLabel('a11y/...', params)ort('a11y/...')directly when you don't need the disabled-state short-circuit. Add the key to all 13 locale files inpackage/src/i18n/(ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr). You can omit a11y keys if a button contains a text label that describes what it does. - Gate behavior on
useAccessibilityContext().enabled. A11y is opt-in. New listeners, subscriptions, and announcer mounts must be no-ops whenenabledis false. NewaccessibilityRole/accessibilityStateprops are fine to render unconditionally — they cost ~zero. - One focusable target per action. Don't nest
PressableinsidePressable. Mark inner decorative views withaccessibilityElementsHidden(iOS) +importantForAccessibility='no-hide-descendants'(Android) so the parent carries the label. - Decorative visuals stay hidden from AT. Icon-only buttons must carry an
accessibilityLabelon the wrapper, and the SVG icon should be hidden. - Backward-compatible. All new props are optional. Component override pattern (
WithComponents) must continue to work. - Floating overlays need a tall parent for Android a11y. Android's accessibility framework uses each view's measured layout bounds (
getBoundsInScreen()) to decide what's focusable at a given screen coordinate. Children rendered outside their parent's measured rect get pruned / reported with inverted (empty) bounds — RN doesn't clip them by default so the visual looks fine, but TalkBack can't focus them anduiautomator dumpshows degenerate[x,y][x,y]rects. Implication: when mounting a floating overlay (autocomplete picker, popover, tooltip), pick a parent whose measured bounds contain the rendered area. Aflex: 1Channel-area parent works; aposition: absolutewrapper inside a small input-row container does not. This is whyAutoCompleteSuggestionListis mounted fromMessageList/MessageFlashList(full-screen flex parent) instead ofMessageComposer(~228px composer parent — the suggestion list overflowed it and was a11y-invisible). Verify withadb shell uiautomator dumpafter mounting; if rows showtop > bottom, the parent isn't tall enough.
Diagnosing Android a11y with uiautomator dump
When TalkBack ignores a view, can't focus a row, or seems to focus the wrong thing, dump the a11y tree and read the bounds directly. This was the load-bearing technique behind rule #7.
Procedure:
# 1. Put the app in the state you want to inspect (open the suggestion list, modal, etc.)
adb shell uiautomator dump /sdcard/window_dump.xml
adb pull /sdcard/window_dump.xml ./window_dump.xml
# 2. Find your view. Grep by a known accessibilityLabel, text, or resource-id.
grep -A2 'text="@channel"' window_dump.xml
grep -B1 -A1 'content-desc="Mention suggestions available"' window_dump.xml
Reading the output: each <node> has bounds="[left,top][right,bottom]" in screen pixels.
Symptom in bounds |
Meaning |
|---|---|
[0,0][0,0] |
View never measured (mid-mount or detached from a11y tree). |
top > bottom or left > right |
Clipped by parent — getBoundsInScreen() clamped to a smaller ancestor. TalkBack treats this as empty. Move the mount to a taller parent. |
| Bounds outside the screen | Off-screen or pushed by keyboard; TalkBack won't focus it. |
Bounds present, clickable="true", focusable="true", but still unreachable |
Check importantForAccessibility chain and sibling z-order — something opaque may be above it. |
Other useful node attributes:
class— the underlying Android View class (android.widget.HorizontalScrollView, etc.). Useful when an RN component compiles to something unexpected.package— confirms you're looking at your app, not the system UI.clickable,focusable,enabled— these must all be true for a row to take TalkBack focus.content-desc— what TalkBack will speak. If empty when you expected anaccessibilityLabel, the prop didn't bind to the right native view.
Caveats:
- The dump is a single snapshot. If the view animates in, dump after the animation settles.
- TalkBack can affect what gets dumped on some devices — turn it off when diagnosing layout, on when diagnosing focus order.
- The XML reflects native bounds after RN's layout pass, so a wrong dump usually means RN gave Android wrong layout, not that the dump lied.
Where to put what
- Foundation primitives →
package/src/a11y/(utilities + low-level hooks). - Runtime announcer infra →
package/src/components/Accessibility/(NotificationAnnouncer,useAccessibilityAnnouncer,useIncomingMessageAnnouncements). - Config + provider →
package/src/contexts/accessibilityContext/, mounted byOverlayProvider. - i18n →
a11y/*keys in all 13 locale JSONs (ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr). - Component-level a11y attributes → in the component itself.
- Platform divergence (iOS vs Android) → use
Platform.OSoruseResolvedModalAccessibilityProps. Don't duplicate the file — RN doesn't need.ios.tsx/.android.tsxsplits for a11y. - Tests → nearest
__tests__/folder; use@testing-library/react-nativesemantic queries (getByRole,getByLabelText).
Patterns to follow
1) Composing accessible names
import { Button, useA11yLabel } from 'stream-chat-react-native';
const labelParams = useMemo(() => ({ count, emoji }), [count, emoji]);
const label = useA11yLabel('a11y/Reaction {{emoji}} by {{count}} users', labelParams);
<Pressable accessibilityLabel={label} accessibilityRole='button' accessibilityState={{ selected }} />
<Button accessibilityLabelKey='a11y/Send message' iconOnly {...buttonProps} />
useA11yLabel returns undefined when accessibility.enabled is false, so the t() call is skipped on hot list paths.
Button centralizes this same behavior for SDK-owned buttons. In SDK code, pass the key/params only. When migrating a released button that already had an accessibilityLabel, make the new translation resolve to the same existing label unless the change is intentionally breaking.
For composite labels (sender + timestamp + body + reactions summary), use composeAccessibilityLabel(...parts) from package/src/a11y/a11yUtils.ts — it filters out empty/null parts and joins with , so screen readers add a brief pause.
2) Live-region announcements
Two complementary mechanisms:
- Imperative:
useAccessibilityAnnouncer()returns(message, priority?) => void. Same shape asstream-chat-react'suseAriaLiveAnnouncer. WrapsAccessibilityInfo.announceForAccessibilitywith sequence/debounce so repeat announcements still re-announce. - Declarative:
accessibilityLiveRegion="polite"(Android only) on a View that re-renders when its label changes.
Use useAnnounceOnStateChange(message, { debounceMs, priority }) for transitions (AI typing, indicators) — it dedups consecutive same-message calls and applies a default 250ms debounce.
Use useAnnounceOnShow(visible, message, { delayMs, priority }) for transient surfaces that appear and disappear repeatedly (modals, sheets, autocomplete pickers). It announces on each visible: false → true transition and resets on hide, so the next show re-announces. The two announcer hooks are not interchangeable: useAnnounceOnStateChange dedupes on string equality (correct for "AI is typing" → "AI is generating"), while useAnnounceOnShow dedupes on visibility transition (correct for "Suggestions available" each time the picker reopens with the same label). Pair with useA11yLabel('a11y/…') for the message so the announcement is i18n'd and gated on the SDK's a11y opt-in.
For incoming messages: use useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList }). It throttles to 1 announcement per second, batches multi-message bursts, and bounds memory at 500 announced ids.
3) Modal / sheet focus trap
Use useResolvedModalAccessibilityProps() and spread the result on the modal root:
const a11yProps = useResolvedModalAccessibilityProps();
<Animated.View {...a11yProps} style={...}>
{/* ... */}
</Animated.View>
This returns:
- iOS:
{ accessibilityViewIsModal: true } - Android:
{ importantForAccessibility: 'yes' } - Either platform when
enabledis false:{}
After opening, set initial focus via AccessibilityInfo.setAccessibilityFocus(findNodeHandle(titleRef.current)) deferred behind requestAnimationFrame so the a11y tree has settled.
4) Gesture alternatives
Mobile gestures (long-press, hold-to-record, pinch/pan) must have a tap-equivalent for SR users. Read the component's mode flag from AccessibilityConfig:
const { audioRecorderTapMode } = useAccessibilityContext();
const screenReaderOn = useScreenReaderEnabled();
const useTapMode = audioRecorderTapMode === 'always' || (audioRecorderTapMode === 'auto' && screenReaderOn);
Three-state semantics: 'auto' (swap when SR is on), 'always' (swap for everyone), 'never' (integrator handles).
5) Reduced-motion
const reduceMotion = useReducedMotionPreference();
const transitionDuration = reduceMotion ? 0 : 250;
Disable spring animations and limit fade durations when this is true.
6) Curated single focus stop for visual content — CompositeAccessibilityProbe
import { CompositeAccessibilityProbe } from 'stream-chat-react-native';
<CompositeAccessibilityProbe label={accessibilityLabel}>
{/* avatars, icons, composed graphics — visually decorative */}
</CompositeAccessibilityProbe>
Wraps non-Text visual content with a single, cross-platform-stable focus stop carrying the provided label. Renders a hidden Text sibling that carries the label + a View accessibilityElementsHidden importantForAccessibility='no-hide-descendants' around the children. Use for avatars, mute icons, isolated badges, composed graphics that should announce as one semantic unit.
Pass the result of useA11yLabel(...) directly — when label is undefined (a11y opt-out), the probe is a no-op and renders children untouched.
Live examples: ChannelAvatar.tsx, ChannelPreviewMutedStatus.tsx, ChannelMessagePreviewDeliveryStatus.tsx.
7) Splicing extra a11y info into compose — HiddenA11yText
import { HiddenA11yText } from 'stream-chat-react-native';
<Pressable>
<Icon />
{selected ? <HiddenA11yText label={useA11yLabel('a11y/you reacted')} /> : null}
</Pressable>
A visually-invisible <Text> that exists only to contribute extra information to a parent's compose loop. Use it to splice in supplementary state ("you reacted", "and N more", "unread") that doesn't have a natural visible Text in the tree.
Different concern from CompositeAccessibilityProbe:
HiddenA11yText— "inject extra a11y-only text into a compose chain"CompositeAccessibilityProbe— "make this whole visual element one focus stop with a curated label"
Live examples: MessageStatus.tsx, ReactionListClustered.tsx, ReactionListItem.tsx.
8) Cross-platform auto-compose on a plain View
<View accessible accessibilityRole='text'>
{/* children whose labels should auto-compose into one announcement */}
</View>
iOS auto-composes descendant labels when a View is accessible={true} without an explicit accessibilityLabel. Android requires the parent to trip a gate — set any of accessibilityRole, accessibilityState, accessibilityActions, or accessibilityLabelledBy. accessibilityRole='text' (or 'none') is the lightest gate-tripper and a no-op for iOS composition.
Pressable defaults accessibilityRole='button', so it auto-trips the gate. Plain View accessible={true} without a role does NOT — Android falls back to its default heuristic (reads one visible Text descendant only).
Live example: MessageFooter.tsx — <View accessible accessibilityRole='text'> makes the footer one focus stop on both platforms reading "Read 11:05 AM".
See full memory: rn_android_a11y_compose_gate.md.
9) Drill-in for interactive children inside a Pressable
<Pressable accessible={hasInteractiveContent ? false : undefined} onLongPress={...}>
{/* mix of interactive children — attachments, quoted reply, poll options, etc. */}
</Pressable>
When a Pressable wraps mixed content that includes interactive children, the row's default single-focus-stop behavior subsumes them — screen-reader users can't activate the children individually. Setting accessible={false} on the Pressable removes the row stop, so VO/TalkBack drill into each interactive child. The Pressable's onPress / onLongPress still fire because VO/TalkBack synthesize taps at the focused child's coordinates, which land inside the Pressable's hit area.
Live example: MessageContent.tsx — accessible={hasInteractiveContent ? false : undefined} where hasInteractiveContent covers poll, quoted message, attachments, shared location.
10) Reshow announcements — useAnnounceOnShow
useAnnounceOnShow(visible, useA11yLabel('a11y/Replying to {{user}}', { user: name }));
Announces label once each time visible flips from false to true. Resets on hide, so reshows re-announce — unlike useAnnounceOnStateChange which dedupes consecutive identical strings.
Use for transient surfaces that appear and disappear repeatedly within a session (modals, autocomplete pickers, reply previews) where the user benefits from hearing the affordance on every reappearance.
Live example: Reply.tsx — fires when a reply preview shows in the composer.
11) Screen-reader focus on screen entry — useSetAccessibilityFocus / useScreenReaderMountFocus
// imperative primitive — moves the SR cursor onto a ref/node. NEVER .focus(): no keyboard,
// no first responder. The user still double-taps to act on whatever is focused.
const setAccessibilityFocus = useSetAccessibilityFocus();
setAccessibilityFocus(someRef); // SR-gated, rAF-deferred; reads .current at call time
// declarative on-mount wrapper (MessageComposer uses it on inputBoxRef)
useScreenReaderMountFocus(someRef);
Use to land the SR cursor on the place the user came to act (e.g. the composer) when a message view is entered, instead of an arbitrary focus stop. No-op unless a screen reader is running.
Navigation timing is platform-split, and the SDK stays navigation-agnostic:
- Android forward-nav honors a focus set on mount →
useScreenReaderMountFocus(used internally byMessageComposer). - iOS forward-nav, and back-nav on both platforms, need the focus set after the screen transition finishes — firing mid-transition loses a race with the OS's own screen-entry focus pass. That's navigation-specific, so the integrator wires it (React Navigation
transitionEnd→setAccessibilityFocus). SampleApp reference:useScreenReaderComposerFocusEffect.
Get the composer node via the already-public <Channel setInputRef={...}>; pass a stable setInputRef (an inline arrow re-fires the ref every parent render).
Live: MessageComposer.tsx (useScreenReaderMountFocus(inputBoxRef)); SampleApp useScreenReaderComposerFocusEffect.tsx.
12) Reachable header + the onAccessibilityEscape New-Architecture gotcha
When initial focus moves off the header, keep it reachable via standard gestures:
- Mark the header title
accessibilityRole='header'→ reachable via iOS VoiceOver Headings rotor / jump-to-top (4-finger tap top) and Android TalkBack Headings reading control → swipe up. Android has no bare-swipe "jump to first element" like iOS — reaching the header is reading-control-dependent. CustomaccessibilityActionsare per-node and do NOT bubble, and the Android system Back gesture can't be repurposed to focus (breaks go-back;BackHandlercan't isolate TalkBack from a normal back). - iOS back-scrub →
onAccessibilityEscape={() => navigation.goBack()}on the screen-root view; Android's system Back already pops via the navigation lib.
⚠️ onAccessibilityEscape is INERT on a layout-only View under the New Architecture (Fabric). The VoiceOver scrub just "bonks." Fabric flattens the view to no backing UIView — onAccessibilityEscape is a standalone prop (not in events.bits, not a formsView trigger in ViewShadowNode), and backgroundColor:'transparent' isn't a "meaningful" color, so nothing forces a view. Fix: collapsable={false} on the hosting view (it's the first formsStackingContext term). Applies to any native-only a11y handler under Fabric.
Anti-patterns to avoid
- Hardcoded English
accessibilityLabelstrings inside component code. For SDKButton, useaccessibilityLabelKey='a11y/...'; otherwise useuseA11yLabel('a11y/...')ort('a11y/...'). - Nested focusables:
<Pressable><Pressable>causes VO to stop on each. Mark the outeraccessible={false}or the inneraccessibilityElementsHidden. - Subscribing to
AccessibilityInfoevents whenenabledis false — wastes a listener slot. The provided hooks already gate on this; mirror that pattern. useScreenReaderEnabled()inside list items — toggling SR re-renders every item. Only subscribe in components that actually swap UI on SR (AudioRecorder,ImageGallery,Message's alternative-actions button).- Using live regions to force-announce static modal text — fix the dialog semantics instead (
useResolvedModalAccessibilityProps+ correctaccessibilityRole='alert'). - Auto-focusing the suggestions/listbox of a typeahead on appear — anti-pattern for combobox-style UI. Each keystroke that produces new suggestions would re-steal focus from the active
TextInput, breaking continuous typing. ARIA combobox spec specifically forbids this; iOS VoiceOver and Android TalkBack have the same constraint. Announce on show viauseAnnounceOnShowinstead and rely on standard screen-reader navigation gestures (swipe) for the user to reach the list when they want. - Mutating
AccessibilityInfopolyfill state in tests without restoring — use the mock-builder helpers inpackage/src/mock-builders/accessibility/(or jest.mock the module) and reset between tests. onAccessibilityEscape(or other native-only a11y handlers) on a flattened View under the New Architecture — Fabric drops the backing view and the handler never fires (VoiceOver bonks). Addcollapsable={false}(or a view-forming prop).- Repurposing the Android system Back gesture to move focus —
BackHandlercan't distinguish TalkBack's back-gesture from a normal back, and consuming it breaks go-back for everyone. - Expecting a bare swipe-up to reach a header on Android — TalkBack only jumps to headings/controls when the reading control is set to that granularity; there's no iOS-style "jump to first element" gesture.
- Passing an unstable
setInputRef(inline arrow) to<Channel>— re-fires the ref callback on every parent render (detach→null→reattach); wrap it in a stableuseCallback. - Using
.focus()to move screen-reader focus — that activates the input (opens the keyboard / makes it first responder). To only move the SR cursor, useuseSetAccessibilityFocus(AccessibilityInfo.setAccessibilityFocus), notinputBoxRef.focus().
Testing requirements per change
Minimum:
- Unit tests for new keyboard/focus/semantics behavior in nearest
__tests__/. - Use
@testing-library/react-nativesemantic queries:getByRole,getByLabelText,getByA11yState,getByA11yValue.
Recommended for non-trivial changes:
- Render with
<OverlayProvider accessibility={{ enabled: true, forceScreenReaderMode: true }}>and assert the accessible variant renders. - Render with
<OverlayProvider accessibility={{ enabled: false }}>and assert the legacy behavior is unchanged (no extra buttons, no listeners).
Execution checklist (copy this when making an a11y change)
- Identified the interaction type (button / menuitem / dialog / progressbar / radio / checkbox / live region / image)
- Picked a native element first; ARIA-style
accessibilityRoleonly when necessary - Composed the accessible name via
Button accessibilityLabelKey='a11y/...'oruseA11yLabel('a11y/...')(not hardcoded) - Added the new
a11y/*key to all 12 locale JSONs and ranyarn build-translations - Set
accessibilityStatefor stateful widgets (disabled,selected,checked,busy,expanded) - Decorative visuals hidden from AT (
accessibilityElementsHidden/importantForAccessibility='no-hide-descendants') - Modal surfaces use
useResolvedModalAccessibilityProps - Native-only a11y handlers (
onAccessibilityEscape, etc.) sit on a non-flattened view (collapsable={false}under the New Architecture) - New behavior (announcers, listeners) gated on
useAccessibilityContext().enabled - Tested with
<OverlayProvider accessibility={{ enabled: true, forceScreenReaderMode: true }}>andenabled: false - Verified
yarn lintpasses (validate-translationsenforces non-emptya11y/*keys) - Verified
yarn tsc --noEmitpasses (RN's a11y prop types are strict aboutboolean | null | undefined)
Reference files (in this repo)
package/src/contexts/accessibilityContext/AccessibilityContext.tsx— config schema + provider + imperative announcer context.package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts— port of stream-chat-react's hook.package/src/components/Accessibility/CompositeAccessibilityProbe.tsx— curated-single-focus-stop wrapper for visual content (avatar, icons, badges).package/src/components/Accessibility/HiddenA11yText.tsx— visually-invisible Text that splices extra info into a parent's compose chain ("you reacted", "and N more", etc).package/src/a11y/hooks/useA11yLabel.ts— translated-label-or-undefined.package/src/a11y/hooks/useAnnounceOnStateChange.ts— announce on string-change with dedup.package/src/a11y/hooks/useAnnounceOnShow.ts— announce onvisible: false → truetransitions, resets on hide (no dedup).package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts— modal a11y props.package/src/a11y/hooks/useAnnounceOnShow.ts— announce-on-visible helper for transient surfaces.package/src/a11y/hooks/useSetAccessibilityFocus.ts— imperative "move the SR cursor onto a ref/node" primitive (never.focus()); SR-gated, rAF-deferred.package/src/a11y/hooks/useScreenReaderMountFocus.ts— focus-a-ref-on-mount wrapper (composer uses it oninputBoxRef); the Android forward-nav path.examples/SampleApp/src/utils/useScreenReaderComposerFocusEffect.tsx— integration reference: React NavigationtransitionEnd→setAccessibilityFocus, stablesetInputRef, andonAccessibilityEscape+collapsable={false}on the screen root.package/src/components/ui/Avatar/Avatar.tsx— example ofname+useA11yLabelusage.package/src/components/UIComponents/BottomSheetModal.tsx— example ofuseResolvedModalAccessibilityPropsanduseAnnounceOnShow.package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx— example ofuseAnnounceOnStateChange.package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx— example ofuseAnnounceOnShowwith a per-trigger label (mention/command/emoji).package/src/components/Message/MessageItemView/MessageFooter.tsx— example of cross-platform auto-compose on a View (accessible + accessibilityRole='text').package/src/components/Message/MessageItemView/MessageContent.tsx— example of conditional drill-in (accessible={hasInteractiveContent ? false : undefined}).
Cross-SDK parity
API shapes mirror stream-chat-react#3146:
useAccessibilityAnnouncer≈ React'suseAriaLiveAnnounceruseIncomingMessageAnnouncements— same params, same throttle/batch logica11y/*i18n namespace shared<NotificationAnnouncer />— same component name,connection-state-only on RN since RN has no shared notifications queue
When changing one SDK's a11y API, mirror it in the other where the platforms agree.