Accesibilidad en formularios
Etiquetas, errores anunciados a VoiceOver/TalkBack y gestión del foco.
Busca en todas las páginas de la documentación
Etiquetas, errores anunciados a VoiceOver/TalkBack y gestión del foco.
Tarjeta de referencia rápida - lista para copiar y pegar.
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();
}Cuándo usarlo: Cualquier formulario en producción - reseña de tienda, adquisición empresarial y usuarios reales con VoiceOver/TalkBack habilitado. Los formularios móviles fallan auditorías de accesibilidad con mayor frecuencia en etiquetas faltantes, errores silenciosos y pérdida de foco después de la validación.
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 },
});Lo que demuestra:
Text con nativeID emparejado a accessibilityLabelledBy en TextInput.accessibilityHint para orientación de formato; accessibilityLiveRegion="assertive" para texto de validación.accessibilityInvalid marca el campo para tecnología de asistencia cuando Zod falla.onInvalid enfoca el primer campo incorrecto y llama a announceForAccessibility.TextInput debe exponer nombre (etiqueta), rol (textbox es el predeterminado), valor, hint y estado (deshabilitado, inválido).Text visible.accessibilityLiveRegion="polite" o "assertive" pide a TalkBack/VoiceOver que lo lea. Para errores por lotes en tiempo de envío, AccessibilityInfo.announceForAccessibility proporciona un resumen hablado único..focus() en el primer TextInput inválido después del envío fallido coincide con web focus() en campos aria-invalid - crítico porque los usuarios móviles pueden no ver la parte superior de un ScrollView largo.accessibilityRole (por ejemplo, "radiogroup") y una etiqueta de grupo, no tres cuadros de texto aislados.| Prop | Propósito | Ejemplo |
|---|---|---|
accessibilityLabel | Nombre hablado cuando la etiqueta visible es insuficiente o solo icono | "Email, required" |
accessibilityHint | Cómo interactuar / expectativa de formato | "Ten digits, no dashes" |
accessibilityLabelledBy | Vincularse a la nativeID de etiqueta visible (Android + iOS 13+) | emailLabel |
accessibilityInvalid | Marca el campo como fallido en validación | true cuando hay error presente |
accessibilityState | Deshabilitado, seleccionado, marcado, expandido | { disabled: true } en envío |
accessibilityLiveRegion | Anuncia texto de error dinámico | "assertive" en Text de error |
accessibilityRole | Anulación de rol semántico | "button" en Pressable de envío personalizado |
// 1. Texto de error en línea - montaje/desmontaje dispara región viva
{error && (
<Text accessibilityRole="alert" accessibilityLiveRegion="assertive">
{error}
</Text>
)}
// 2. Resumen de envío - un mensaje hablado para múltiples fallos
const count = Object.keys(errors).length;
if (count > 0) {
await AccessibilityInfo.announceForAccessibility(
`${count} fields need attention. ${firstMessage}`,
);
}
// 3. Confirmación de éxito - no confíes solo en toast
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 se tipea a nombres de campos
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 preservan tipado de ref para gestión de foco.accessibilityLabelledBy acepta una nativeID de string en versiones recientes de iOS/Android; retrocede a accessibilityLabel solo en destinos más antiguos si es necesario.nativeIDs de etiqueta únicas por pantalla - las colisiones rompen la asociación de etiqueta.Etiquetas solo de placeholder - VoiceOver lee el campo vacío como "text field" sin nombre. Corrección: Etiqueta Text visible + accessibilityLabel.
Errores mostrados solo en borde rojo - Los usuarios daltónicos y los usuarios de lectores de pantalla pierden el fallo. Corrección: Error de texto + accessibilityInvalid + región viva o anuncio.
Botones de borrar solo de icono - Anunciados como "button" sin etiqueta. Corrección: accessibilityLabel="Clear email field".
Envío sin movimiento de foco - El usuario no oye nada y asume que la aplicación se congeló. Corrección: onInvalid - setFocus(firstError) + announceForAccessibility.
Anuncios duplicados - Región viva en cada campo se dispara seis veces en envío. Corrección: Errores en línea por campo en blur; anuncio de resumen único en envío.
Disparadores de selector personalizado sin valor - "Button" sin fecha actual hablada. Corrección: accessibilityLabel={Date, ${formatted}} - ver artículo de selectores.
Envío deshabilitado sin explicación - El estado de botón disabled puede no explicar por qué. Corrección: accessibilityState={{ disabled: true }} y texto de ayuda arriba cuando el formulario está incompleto.
keyboardShouldPersistTaps omitido - El doble toque de VoiceOver en envío no se dispara mientras el teclado está abierto. Corrección: "handled" en formulario ScrollView / KeyboardAwareScrollView.
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
Etiqueta visible + accessibilityLabelledBy | Predeterminado para todos los campos de texto | Búsqueda decorativa de un solo campo con accessibilityLabel explícito en la entrada |
accessibilityLiveRegion en Text de error | Errores de campo en línea después de blur/envío | Errores de servidor por lotes - usa announceForAccessibility una vez |
AccessibilityInfo.announceForAccessibility | Resumen de envío, toast de éxito, cambios de paso | Reemplazar etiquetas por campo completamente |
Primitivo Field de sistema de diseño | Aplicar contrato de etiqueta/error/hint en toda la aplicación | Prototipo único - aún copia el patrón de prop |
eslint-plugin-react-native-a11y | Guardia de CI para etiquetas faltantes en TextInput | Sustituto para pruebas de dispositivo con tecnología de asistencia real |
| Configuración de accesibilidad de plataforma | Respetar AccessibilityInfo.isReduceMotionEnabled para validación animada | Construir un "modo accesible" separado - integra en la interfaz de usuario predeterminada |
accessibilityLabel que coincida o la complemente.accessibilityLabel="Search" explícito en el TextInput.accessibilityLiveRegion o accessibilityRole="alert" está establecido.announceForAccessibility envía un anuncio global - útil después de envío cuando el foco se mueve.hint - los errores cambian; los hints deben ser instrucciones estables.accessibilityLabel o texto de etiqueta visible.accessibilityState no tiene required - la etiqueta hablada es el enfoque confiable.* sin la palabra "required".react-hook-form: handleSubmit(onValid, onInvalid) y setFocus(firstFieldName) en onInvalid.refs.find + .focus() en el primer campo con error.ScrollView - scrollTo o refs de scroll conscientes del teclado.Switch necesita su propio accessibilityLabel describiendo qué alterna.accessibilityRole="radio" / radiogroup en un cluster Pressable.accessibilityState={{ checked: value }}.secureTextEntry - la etiqueta debe ser "Password, required".hint - repite reglas concisas en texto de ayuda visible.setError("email", { message }) de formulario.announceForAccessibility en un banner Text con accessibilityRole="alert".announceForAccessibility("Step 2 of 4, Delivery address").requestAnimationFrame).eslint-plugin-react-native-a11y detecta accessibilityLabel faltante en algunos componentes.Pressable con accessibilityRole="button" y etiqueta incluyendo valor actual.maxFontSizeMultiplier solo en pantallas densas con cuidado.TextInput, keyboardType y cadena de focosetFocus, setError y onInvalidaccessibilityRole y accessibilityState en PressableVersiones de stack: Esta página fue escrita para React 19.2.3, React Native 0.86.0 y Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última actualización: 16 jul 2026