Skip to main content

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 what role="menu" promises.
  • Escape dismisses. A Dialog with dismissable={false} and a BottomSheet with dismissable={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. Dialog and the modal BottomSheet set accessibilityViewIsModal, 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, Menu or modal BottomSheet returns 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.

  • Menu is 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 hitSlop that 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.