Accessibility in Forms
Labels, errors announced to VoiceOver/TalkBack, and focus management.
Search across all documentation pages
Labels, errors announced to VoiceOver/TalkBack, and focus management.
Quick-reference recipe card - copy-paste ready.
import { useRef } from "react";
import {
AccessibilityInfo,
Pressable,
Text,
TextInput,
View,
} from "react-native";
export function AccessibleField({
label,
hint,
error,
value,
onChangeText,
}: {
label: string;
hint?: string;
error?: string;
value: string;
onChangeText: (t: string) => void;
}) {
const inputRef = useRef<TextInput>(null);
return (
<View style={{ gap: 4 }}>
<Text nativeID="emailLabel" style={{ fontWeight: "600" }}>
{label}
</Text>
<TextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
accessibilityLabel={label}
accessibilityHint={hint}
accessibilityLabelledBy="emailLabel"
accessibilityInvalid={!!error}
/>
{error ? (
<Text
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
style={{ color: "#dc2626" }}
>
{error}
</Text>
) : null}
</View>
);
}
export async function announceFormError(message: string) {
await AccessibilityInfo.announceForAccessibility(message);
}
export function focusFirstError(
refs: Array<React.RefObject<TextInput | null>>,
errors: boolean[],
) {
const idx = errors.findIndex(Boolean);
if (idx >= 0) refs[idx]?.current?.focus();
}When to reach for this: Any production form - store review, enterprise procurement, and real users with VoiceOver/TalkBack enabled. Mobile forms fail accessibility audits most often on missing labels, silent errors, and lost focus after validation.
import { zodResolver } from "@hookform/resolvers/zod";
import { forwardRef, useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import {
AccessibilityInfo,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email address"),
phone: z
.string()
.regex(/^\d{10}$/, "Enter a 10-digit phone number without spaces"),
});
type FormValues = z.infer<typeof schema>;
const FIELD_ORDER = ["name", "email", "phone"] as const;
export function ContactForm({ onSuccess }: { onSuccess: () => void }) {
const nameRef = useRef<TextInput>(null);
const emailRef = useRef<TextInput>(null);
const phoneRef = useRef<TextInput>(null);
const refs = [nameRef, emailRef, phoneRef];
const {
control,
handleSubmit,
formState: { errors },
setFocus,
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "", phone: "" },
mode: "onTouched",
});
const onInvalid = async () => {
const first = FIELD_ORDER.find((k) => errors[k]);
if (first) {
setFocus(first);
const message = errors[first]?.message ?? "Fix the highlighted fields";
await AccessibilityInfo.announceForAccessibility(message);
}
};
const onSubmit = handleSubmit(async (data) => {
await fakeSubmit(data);
await AccessibilityInfo.announceForAccessibility("Message sent successfully");
onSuccess();
}, onInvalid);
return (
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.heading} accessibilityRole="header">
Contact us
</Text>
<Text style={styles.sub}>
Required fields are marked. Errors are announced automatically.
</Text>
<Controller
control={control}
name="name"
render={({ field, fieldState }) => (
<Field
ref={nameRef}
label="Full name"
required
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
<Controller
control={control}
name="email"
render={({ field, fieldState }) => (
<Field
ref={emailRef}
label="Email"
required
hint="We reply within one business day"
keyboardType="email-address"
autoCapitalize="none"
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
<Controller
control={control}
name="phone"
render={({ field, fieldState }) => (
<Field
ref={phoneRef}
label="Phone"
hint="Ten digits, no dashes"
keyboardType="phone-pad"
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
<Pressable
style={styles.submit}
onPress={onSubmit}
accessibilityRole="button"
accessibilityLabel="Send message"
>
<Text style={styles.submitText}>Send</Text>
</Pressable>
</ScrollView>
);
}
type FieldProps = {
label: string;
value: string;
onChangeText: (t: string) => void;
onBlur: () => void;
error?: string;
hint?: string;
required?: boolean;
keyboardType?: "default" | "email-address" | "phone-pad";
autoCapitalize?: "none" | "sentences";
};
const Field = forwardRef<TextInput, FieldProps>(function Field(
{
label,
value,
onChangeText,
onBlur,
error,
hint,
required,
keyboardType = "default",
autoCapitalize = "sentences",
},
ref,
) {
const labelId = `${label.replace(/\s/g, "")}Label`;
const errorId = `${label.replace(/\s/g, "")}Error`;
const a11yLabel = required ? `${label}, required` : label;
return (
<View style={styles.field}>
<Text nativeID={labelId} style={styles.label}>
{label}
{required ? " *" : ""}
</Text>
<TextInput
ref={ref}
style={[styles.input, error && styles.inputError]}
value={value}
onChangeText={onChangeText}
onBlur={onBlur}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize}
accessibilityLabel={a11yLabel}
accessibilityHint={hint}
accessibilityLabelledBy={labelId}
accessibilityInvalid={!!error}
/>
{error ? (
<Text
nativeID={errorId}
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
style={styles.error}
>
{error}
</Text>
) : null}
</View>
);
});
async function fakeSubmit(_data: FormValues) {
await new Promise((r) => setTimeout(r, 300));
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 16 },
heading: { fontSize: 24, fontWeight: "700" },
sub: { fontSize: 14, color: "#64748b", marginBottom: 4 },
field: { gap: 6 },
label: { fontSize: 14, fontWeight: "600" },
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 8,
padding: 14,
fontSize: 16,
},
inputError: { borderColor: "#dc2626" },
error: { fontSize: 13, color: "#dc2626" },
submit: {
backgroundColor: "#2563eb",
padding: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
submitText: { color: "#fff", fontWeight: "600", fontSize: 16 },
});What this demonstrates:
Text with nativeID paired to accessibilityLabelledBy on TextInput.accessibilityHint for format guidance; accessibilityLiveRegion="assertive" for validation text.accessibilityInvalid flags the field for assistive tech when Zod fails.onInvalid focuses the first bad field and calls announceForAccessibility.TextInput must expose name (label), role (textbox is default), value, hint, and state (disabled, invalid).Text label.accessibilityLiveRegion="polite" or "assertive" asks TalkBack/VoiceOver to read it. For submit-time batch errors, AccessibilityInfo.announceForAccessibility gives a single spoken summary..focus() on the first invalid TextInput after failed submit matches web focus() on aria-invalid fields - critical because mobile users may not see the top of a long ScrollView.accessibilityRole (e.g., "radiogroup") and a group label, not three isolated textboxes.| Prop | Purpose | Example |
|---|---|---|
accessibilityLabel | Spoken name when visible label is insufficient or icon-only | "Email, required" |
accessibilityHint | How to interact / format expectation | "Ten digits, no dashes" |
accessibilityLabelledBy | Tie to visible label nativeID (Android + iOS 13+) | emailLabel |
accessibilityInvalid | Marks field as failing validation | true when error present |
accessibilityState | Disabled, selected, checked, expanded | { disabled: true } on submit |
accessibilityLiveRegion | Announce dynamic error text | "assertive" on error Text |
accessibilityRole | Semantic role override | "button" on custom submit Pressable |
// 1. Inline error text - mount/unmount triggers live region
{error && (
<Text accessibilityRole="alert" accessibilityLiveRegion="assertive">
{error}
</Text>
)}
// 2. Submit summary - one spoken message for multiple failures
const count = Object.keys(errors).length;
if (count > 0) {
await AccessibilityInfo.announceForAccessibility(
`${count} fields need attention. ${firstMessage}`,
);
}
// 3. Success confirmation - do not rely on toast alone
await AccessibilityInfo.announceForAccessibility("Profile saved");import type { TextInput } from "react-native";
import type { RefObject } from "react";
type InputRef = RefObject<TextInput | null>;
function focusField(ref: InputRef) {
ref.current?.focus();
}
// react-hook-form setFocus is typed to field names
import type { FieldPath } from "react-hook-form";
function focusByName<T extends Record<string, unknown>>(
setFocus: (name: FieldPath<T>) => void,
name: FieldPath<T>,
) {
setFocus(name);
}forwardRef field components preserve ref typing for focus management.accessibilityLabelledBy accepts a string nativeID on iOS/Android recent versions; fall back to accessibilityLabel alone on older targets if needed.nativeIDs unique per screen - collisions break the label association.Placeholder-only labels - VoiceOver reads the empty field as "text field" with no name. Fix: Visible Text label + accessibilityLabel.
Errors only shown in red border - Color-blind users and screen reader users miss the failure. Fix: Text error + accessibilityInvalid + live region or announcement.
Icon-only clear buttons - Announced as unlabeled "button". Fix: accessibilityLabel="Clear email field".
Submit with no focus move - User hears nothing and assumes the app froze. Fix: onInvalid → setFocus(firstError) + announceForAccessibility.
Duplicated announcements - Live region on every field fires six times on submit. Fix: Per-field inline errors on blur; single summary announcement on submit.
Custom picker triggers without value - "Button" with no current date spoken. Fix: accessibilityLabel={Date, ${formatted}} - see pickers article.
Disabled submit with no explanation - Button state disabled may not explain why. Fix: accessibilityState={{ disabled: true }} and helper text above when form incomplete.
keyboardShouldPersistTaps omitted - VoiceOver double-tap on submit does not fire while keyboard is open. Fix: "handled" on form ScrollView / KeyboardAwareScrollView.
| Alternative | Use When | Don't Use When |
|---|---|---|
Visible label + accessibilityLabelledBy | Default for all text fields | Decorative single-field search with explicit accessibilityLabel on the input |
accessibilityLiveRegion on error Text | Inline field errors after blur/submit | Batch server errors - use announceForAccessibility once |
AccessibilityInfo.announceForAccessibility | Submit summary, success toast, step changes | Replacing per-field labels entirely |
Design-system Field primitive | Enforce label/error/hint contract app-wide | One-off prototype - still copy the prop pattern |
eslint-plugin-react-native-a11y | CI guard for missing labels on TextInput | Substitute for device testing with real assistive tech |
| Platform accessibility settings | Respect AccessibilityInfo.isReduceMotionEnabled for animated validation | Building a separate "accessible mode" - bake into default UI |
accessibilityLabel that matches or supplements it.accessibilityLabel="Search" on the TextInput.accessibilityLiveRegion or accessibilityRole="alert" is set.announceForAccessibility pushes a global announcement - useful after submit when focus moves.hint - errors change; hints should be stable instructions.accessibilityLabel or visible label text.accessibilityState does not have required - spoken label is the reliable approach.* without the word "required".react-hook-form: handleSubmit(onValid, onInvalid) and setFocus(firstFieldName) in onInvalid.refs.find + .focus() on the first field with an error.ScrollView - scrollTo or keyboard-aware scroll refs.Switch needs its own accessibilityLabel describing what it toggles.accessibilityRole="radio" / radiogroup on a Pressable cluster.accessibilityState={{ checked: value }}.secureTextEntry - label should still be "Password, required".hint - repeat concise rules in visible helper text.setError("email", { message }).announceForAccessibility on a banner Text with accessibilityRole="alert".announceForAccessibility("Step 2 of 4, Delivery address").requestAnimationFrame).eslint-plugin-react-native-a11y catches missing accessibilityLabel on some components.Pressable with accessibilityRole="button" and label including current value.maxFontSizeMultiplier on dense screens only with care.TextInput props, keyboardType, and focus chainsetFocus, setError, and onInvalidaccessibilityRole and accessibilityState on PressableStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026