Accessibility
Components ship with MD3 roles, labels and state already wired. This page is about the parts that aren't automatic: what you have to supply, what differs between web and native, and what the library does not yet do.
One spelling, both platforms
Every component announces its state with aria-* props — aria-checked,
aria-selected, aria-expanded, aria-valuenow, aria-disabled.
That is not a web bias. React Native normalizes those props back into
accessibilityState / accessibilityValue for native, while
react-native-web 0.21 reads only the ARIA spelling and silently discards
RN's nested accessibilityState={{ ... }} object. One spelling covers both;
the nested object covers native alone.
It matters if you override accessibility props yourself:
// Reaches a screen reader on iOS, Android and web.
<Pressable aria-selected={selected} role="tab" />
// Works on native. On web the state never reaches the DOM.
<Pressable accessibilityState={{ selected }} accessibilityRole="tab" />
TextInput is the exception — it does not normalize, so TextField carries
both spellings internally.
Icons are decorative
Every icon a component renders for you — leadingIcon, trailingIcon, a
Checkbox's check mark, a Switch's thumb glyph, a Tab's icon — sits inside
a node marked aria-hidden, so it never reaches the accessibility tree. The
accessible name of a control comes from its visible text, or from the
accessibilityLabel you pass, and never from the icon.
That is not cosmetic. An icon font renders its glyph as text in a
private-use-area codepoint, and React Native merges the text of descendant
nodes into an accessible ancestor's name. Without the flag,
<Button leadingIcon="plus">Add Item</Button> announced as ", Add
Item", and a checked Checkbox — which has no text of its own — announced as
the check glyph alone. A screen reader reads a private-use codepoint as nothing,
or as an unknown symbol.
One consequence worth knowing if you test with
@testing-library/react-native: its queries skip elements hidden from
accessibility by default, so an assertion that an icon rendered has to opt in.
// Finds nothing — the icon is hidden from the accessibility tree.
expect(screen.queryByText('check')).toBeNull()
// Asserts the icon rendered.
expect(screen.getByText('check', { includeHiddenElements: true })).toBeTruthy()
If you pass your own node as an icon, the same applies — it is wrapped for you, so it needs no flag of its own.
Touch targets
Several MD3 Expressive size tokens are deliberately smaller than the 48dp
WCAG 2.5.5 / MD3 minimum — a Button at xs and a ButtonGroup item at
extraSmall are 32dp tall, and a Switch track is 32dp. On native those
controls carry a hitSlop sized to bring the touch area back to 48dp without
moving a single pixel of layout, so the small sizes stay usable.
ButtonGroup grows its target vertically only. Items in the connected
variant sit 2dp apart, so horizontal slop would make each item's touch area
overlap its neighbour's.
This does not apply on web. react-native-web does not implement hitSlop,
so on web these controls are exactly their token size. If you use xs /
extraSmall on a touch-capable web build, give them room yourself.
What you have to supply
The library cannot invent these.
Labels for anything icon-only. IconButton and FAB with no label render
a glyph and nothing else. Pass accessibilityLabel:
<IconButton icon="delete" accessibilityLabel="Delete message" />
<FAB icon="add" accessibilityLabel="New event" />
Chip's trailing close affordance is the exception — it labels itself as
Remove {label}, composing in the chip's own text, because it is a second
accessibility target inside what looks like one control and "Remove" alone
would not say which chip. That default is English, so localized apps should
pass the whole string:
<Chip variant="input" onClose={remove} closeAccessibilityLabel="Salmon entfernen">
Salmon
</Chip>
Labels for bare selection controls. Checkbox, Radio and Switch draw
the control only — they have no label prop, because MD3 puts the label in the
surrounding row. A control with visible text next to it still needs the text
associated with it:
<ListItem
headlineText="Push notifications"
trailingContent={<Switch value={on} onValueChange={setOn} />}
/>
// Standalone, with the text outside any labelled container:
<Row>
<Checkbox value={agreed} onValueChange={setAgreed} accessibilityLabel="Agree to terms" />
<Text>I agree to the terms</Text>
</Row>
A name for a Dialog without a plain-text headline. Dialog lifts its
accessible name from Dialog.Title when the headline is a string. Build the
headline out of nodes and there is no single string to announce, so pass
accessibilityLabel yourself.
Dialogs, menus and sheets on web
Portal-rendered surfaces are appended at the end of the tree, so on web the
browser's tab order would walk straight past them into the content behind.
aria-modal tells assistive technology a surface is modal; it is not a focus
boundary. Dialog, Menu and the modal BottomSheet therefore contain focus
themselves:
- Focus moves into the surface when it opens — the first focusable element inside it, or the surface itself when it has none.
- Tab and Shift-Tab cycle within the surface. In a
Menu, Arrow Down and Arrow Up move between items as well, which is whatrole="menu"promises. - Escape dismisses. A
Dialogwithdismissable={false}and aBottomSheetwithdismissable={false}ignore it, matching what a scrim tap does. - Closing returns focus to whatever held it before — usually the control that opened the surface.
None of this runs on native, where there is no tab order to contain: iOS and
Android take the screen reader out of the background from
accessibilityViewIsModal, and Android's hardware back button already
dismisses.
Menu's trigger is annotated for you. It gets aria-haspopup="menu" and an
aria-expanded that tracks the menu, in both the controlled and uncontrolled
forms, so a screen reader announces the control as opening a menu rather than
as a plain button.
Dialog vs alertdialog
Dialog announces itself as role="dialog". Assistive technology treats
alertdialog as an interruption, so it is opt-in — use it for something the
user must resolve, not for every confirmation:
<Dialog visible={visible} onDismiss={close} role="alertdialog">
<Dialog.Title>Delete this project?</Dialog.Title>
<Dialog.Content>This cannot be undone.</Dialog.Content>
</Dialog>
Text fields
TextField connects its own supporting text — the error message when there is
one — to the input. On web that is aria-describedby pointing at the
supporting-text node; on native, which has no equivalent, the same text becomes
the input's accessibilityHint. errorText (or error) also sets
aria-invalid on web, so the field is announced as invalid and not merely
described.
Both come from props you already pass:
<TextField
label="Email"
value={email}
onChangeText={setEmail}
errorText={invalid ? 'Enter a valid address' : undefined}
/>
A trailingIcon is a button only when you give it onTrailingIconPress. With
a handler it is a real control and needs a name — pass
trailingIconAccessibilityLabel, because the icon itself is hidden and cannot
supply one. Without a handler the icon is pure decoration, so it leaves the
accessibility tree entirely rather than announcing itself as a disabled,
unnamed button.
<TextField
label="Search"
trailingIcon="close"
onTrailingIconPress={clear}
trailingIconAccessibilityLabel="Clear search"
/>
Tooltips
A tooltip renders in a portal, far from its anchor in the DOM, so nothing
connects the two for a keyboard or screen-reader user. While a tooltip is
shown, its anchor carries aria-describedby pointing at it. This is web-only —
React Native has no concept of one view describing another, and on touch a
tooltip is a long-press affordance rather than something a screen reader
narrates.
Reduced motion
Handled by the theme, not by you — see Motion. Every animated component collapses to a hard cut when the OS setting is on.
Known limitations
Stated rather than implied, because they affect what you can promise your own users:
-
Native modal containment does not work.
Dialogand the modalBottomSheetsetaccessibilityViewIsModal, but the flag hides the flagged view's siblings, and a portal surface's siblings are other portal layers — your app content sits one level up, as a sibling of the portal outlet, out of the flag's reach. A screen reader can therefore swipe past an open dialog into the content behind it on iOS and Android. Web is unaffected: focus containment there is real, and does not depend on this flag.Fixing it means the portal host owning the flag rather than the surface, which is a structural change we have not made. If your app depends on containment today, the workaround is to mark your own content branch
importantForAccessibility="no-hide-descendants"(Android) /accessibilityElementsHidden(iOS) while a modal surface is open. -
Nothing restores the screen-reader cursor on native. Closing a
Dialog,Menuor modalBottomSheetreturns keyboard focus on web, but the native reader cursor is not moved back to the control that opened the surface — it falls back to wherever the platform puts it, usually the top of the screen. On web, focus return works as described above. -
Menuis deliberately not marked modal on native. Marking the surface modal removes its siblings from the accessibility tree, and the dismiss region behind the menu is a sibling — it is the only exit a screen-reader user has. Containing the screen reader here needs the portal host to own the flag, which is a change we have not made. -
role="menu"items are Tab stops on web, not a roving-tabindex group. Arrow keys work; the strict ARIA menu pattern would also make the item group a single tab stop. -
RTL has not been swept. Everything routes through
selectRTL, but no platform pass has been run against a right-to-left locale. -
Touch targets are only corrected on native. The
hitSlopthat lifts the smallest size tokens to 48dp is a no-op under react-native-web, so a touch-capable web build gets the token size. See "Touch targets" above.
The first two are pinned as failing tests in
packages/components/src/__tests__/screen-reader-native.test.tsx, so they are
tracked rather than merely known. Both are implementation gaps behind a settled
API — closing them needs no change to any prop, so neither is a breaking
change to fix.
Beyond these, the library's screen-reader behaviour is verified by source audit and by DOM-level tests on web. A full VoiceOver and TalkBack pass over the component catalog has not been run, so announcement wording and traversal order on native are less thoroughly checked than the roles and labels themselves.