State and Grouping
accessibilityState for selected, disabled and checked, collapsing a card into one announcement, and why reading order ignores your layout.
accessibilityState={{ selected?: boolean, disabled?: boolean, checked?: boolean | 'mixed', busy?: boolean, expanded?: boolean }}Three props answer three different questions. accessibilityRole says what a thing is, accessibilityLabel says which one it is, and accessibilityState says what condition it is in. Without the third, a selected tab and an unselected tab sound identical.
State
accessibilityState takes an object with up to five keys: disabled, selected, checked (true, false or "mixed"), busy and expanded. Only pass the keys that apply; a key you leave out is not announced. Each has a web-name alias that is the same prop written shorter: aria-selected, aria-checked, aria-disabled, aria-busy, aria-expanded. Range controls use accessibilityValue with min, max, now and text instead, aliased as aria-valuemin and friends.
Grouping
A card built from four Text nodes is four separate stops. Put accessible={true} on the wrapping View and it becomes one. The label is then composed by joining the child Text nodes with spaces, which rarely reads as a sentence, so usually set accessibilityLabel on the wrapper yourself. Do not nest accessible views: VoiceOver does not focus nested accessibility elements, and TalkBack may focus a parent instead.
Reading order
Focus follows the element tree, not the pixels. flexDirection: "row-reverse", position: "absolute" and negative margins all move things visually and leave the spoken order untouched. Reordering the JSX is the fix to reach for. React Native does also have experimental_accessibilityOrder, which takes an array of descendant nativeIDs and sets the order assistive technology focuses them in, but it is flagged experimental in the docs, likely to change, and not meant for production; note too that it switches off accessibility for any descendant it does not name. To remove something from the order, use aria-hidden, which maps to accessibilityElementsHidden on iOS and importantForAccessibility="no-hide-descendants" on Android.
Example
// On screen: three chips in a row; the active one has a dark background.
// Tapping one makes it active. VoiceOver announces the active chip as
// "Unread, selected, tab" and the others without the word "selected".
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
const FILTERS = ["All", "Unread", "Starred"] as const;
type Filter = (typeof FILTERS)[number];
export default function FilterRow() {
const [active, setActive] = useState<Filter>("All");
return (
<View style={styles.row} accessibilityRole="tablist">
{FILTERS.map((filter) => (
<Pressable
key={filter}
accessibilityRole="tab"
// The colour change is invisible to a screen reader. This is
// the part that tells it which chip is active.
accessibilityState={{ selected: filter === active }}
onPress={() => setActive(filter)}
style={[styles.chip, filter === active && styles.chipActive]}
>
<Text style={[styles.label, filter === active && styles.labelActive]}>
{filter}
</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: "row", gap: 8, padding: 16 },
chip: { minHeight: 44, justifyContent: "center", paddingHorizontal: 16, borderRadius: 22, backgroundColor: "#eee" },
chipActive: { backgroundColor: "#1f1f1f" },
label: { fontSize: 15, color: "#1f1f1f" },
labelActive: { color: "#fff" },
});When to use it
- Tell a screen reader which chip, tab or radio option is currently chosen.
- Make a list row one swipe instead of five without losing its content.
- Explain a greyed-out button, where the only visual cue is a lighter colour.
- Fix an announcement order that does not match what is on screen.
More examples
Collapsing a card into one announcement
A bordered card that a screen reader treats as a single item with one spoken sentence. Use it for list rows and summary cards, where stopping on every line of a repeated card makes a list of twenty items into sixty swipes. Do not group a card that contains more than one control, because grouping hides the inner controls from focus.
import { StyleSheet, Text, View } from "react-native";
type FlightCardProps = {
from: string;
to: string;
departs: string;
seatsLeft: number;
};
export default function FlightCard({ from, to, departs, seatsLeft }: FlightCardProps) {
return (
<View
// One focus stop instead of three.
accessible={true}
// Without a label the reader joins the child Text nodes:
// "LHR JFK 09:40 3 seats left". This reads as a sentence instead.
accessibilityLabel={`${from} to ${to}, departs ${departs}, ${seatsLeft} seats left`}
style={styles.card}
>
<Text style={styles.route}>{`${from} to ${to}`}</Text>
<Text style={styles.time}>{departs}</Text>
<Text style={styles.seats}>{`${seatsLeft} seats left`}</Text>
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, borderRadius: 12, borderWidth: 1, borderColor: "#ddd", gap: 4 },
route: { fontSize: 18, fontWeight: "700" },
time: { fontSize: 15 },
seats: { fontSize: 13, color: "#666" },
});Disabled and busy on a submit button
A full-width button that swaps its text for a spinner while a request is in flight. The spinner is the only visual cue, and a spinner says nothing, so busy carries that fact to the screen reader while the label stays constant. Keeping the label fixed matters: a label that changes to "Submitting" moves focus wording around under the user.
import { ActivityIndicator, Pressable, StyleSheet, Text } from "react-native";
type SubmitButtonProps = {
submitting: boolean;
onSubmit: () => void;
};
export default function SubmitButton({ submitting, onSubmit }: SubmitButtonProps) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Place order"
// Announced, not enforced: "dimmed" on iOS, "disabled" on Android.
accessibilityState={{ disabled: submitting, busy: submitting }}
// This is the prop that actually blocks the press.
disabled={submitting}
onPress={onSubmit}
style={[styles.button, submitting && styles.buttonBusy]}
>
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.label}>Place order</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: { minHeight: 48, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: "#1f6feb" },
buttonBusy: { backgroundColor: "#5b8def" },
label: { color: "#fff", fontSize: 16, fontWeight: "600" },
});An expandable section
A tappable header with a triangle that flips, revealing body text below. It shows the shorthand props in use and aria-hidden on a purely decorative glyph, so the reader says "Shipping, expanded, button" rather than "Shipping, black up-pointing triangle". Reach for expanded on any accordion, filter drawer or show-more control.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
type DisclosureProps = {
title: string;
body: string;
};
export default function Disclosure({ title, body }: DisclosureProps) {
const [open, setOpen] = useState(false);
return (
<View style={styles.block}>
<Pressable
// Shorthand form. role="button" is accessibilityRole="button" and
// aria-expanded is accessibilityState={{ expanded: open }}.
role="button"
aria-expanded={open}
accessibilityLabel={title}
onPress={() => setOpen((wasOpen) => !wasOpen)}
style={styles.header}
>
<Text style={styles.title}>{title}</Text>
{/* Decorative: the chevron repeats what aria-expanded already says. */}
<Text aria-hidden={true} style={styles.chevron}>
{open ? "▲" : "▼"}
</Text>
</Pressable>
{open ? <Text style={styles.body}>{body}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
block: { paddingHorizontal: 16 },
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", minHeight: 48 },
title: { fontSize: 16, fontWeight: "600" },
chevron: { fontSize: 12, color: "#666" },
body: { fontSize: 15, paddingBottom: 16 },
});
Discussion