Módulo 24: Accesibilidad e internacionalización
Objetivos del módulo
- Entender a quién afecta la accesibilidad y qué exige la norma WCAG 2.2
- Saber qué es el árbol de accesibilidad y cómo verlo con tus propias herramientas
- Usar HTML semántico antes que ARIA, y ARIA solo cuando el HTML no llega
- Dar a cada control su rol, su nombre y su estado desde JSX
- Comunicar cambios sin mover el foco con regiones vivas
- Gestionar el foco en diálogos, borrados y cambios de ruta
- Construir con teclado componentes que HTML no trae: pestañas, combobox y similares
- Escribir formularios accesibles, con errores que llegan de la API
- Comprobar contraste, movimiento y tamaño de los objetivos
- Automatizar la revisión con oxlint, axe-core y Playwright, y saber qué se les escapa
- Formatear números, fechas, listas y plurales con
Intl - Traducir una aplicación con
react-i18next: carga perezosa, plurales, tipos y texto de derecha a izquierda
💡 Consejo: Casi todo este módulo funciona igual en React 18.3, porque la accesibilidad vive en el HTML que generas y no en la versión de React. Lo que depende de la versión va marcado:
<Context>yusepara los contextos,<title>en componentes yinertson de 19.0, y las referencias a fragmentos son de 19.3. Los resultados de axe que verás son reales, obtenidos con axe-core 4.14.0 en Chromium. Los lectores de pantalla no se pueden automatizar de esta forma: lo que se dice de ellos debes comprobarlo tú con uno de verdad, como explica la sección 10.
1. Por qué importa
La accesibilidad consiste en que una persona pueda usar tu aplicación aunque no use la pantalla, el ratón o el oído como tú. No es un grupo pequeño ni un caso raro:
| Situación | Cómo usa la web | Qué necesita de ti |
|---|---|---|
| Ceguera | Lector de pantalla que lee en voz alta o en braille | Estructura, nombres y estados correctos |
| Baja visión | Zoom alto, contraste alto | Contraste, texto que se adapta al 200 % |
| Daltonismo | Pantalla normal | Que el color no sea la única señal |
| Movilidad reducida | Teclado, conmutadores, voz | Todo operable sin ratón, foco visible |
| Sordera | Subtítulos | Alternativas al audio |
| Trastornos vestibulares | Pantalla normal | Poder reducir las animaciones |
| Dificultades cognitivas | Pantalla normal | Textos claros, errores que se entienden |
También te beneficia a ti en días normales: el brazo escayolado, el móvil al sol, el vídeo sin sonido en el tren, o el usuario avanzado que nunca toca el ratón.
Tiene además peso legal. La Directiva europea de accesibilidad (European Accessibility Act, 2019/882) exige que los productos y servicios nuevos que entra a regular, entre ellos el comercio electrónico y la banca, sean accesibles desde el 28 de junio de 2025. Las microempresas que prestan servicios (menos de 10 personas y hasta 2 millones de euros de facturación o balance) están exentas.
El estado real de la web
El informe WebAIM Million analiza cada año el millón de páginas de inicio más visitadas. En su edición de 2026, el 95,9 % tenía fallos detectables de forma automática. Los más comunes son casi siempre los mismos, y ninguno exige una técnica avanzada:
| Fallo | Páginas afectadas |
|---|---|
| Texto con poco contraste | 83,9 % |
| Imágenes sin texto alternativo | 53,1 % |
| Campos de formulario sin etiqueta | 51 % |
| Enlaces vacíos | 46,3 % |
| Botones vacíos | 30,6 % |
| Documento sin idioma declarado | 13,5 % |
WCAG, la referencia
Las Web Content Accessibility Guidelines (WCAG) son la norma técnica del W3C. La versión 2.2 se publicó el 5 de octubre de 2023 y se actualizó el 12 de diciembre de 2024. WCAG 3 aún es un borrador inicial. Sus criterios se agrupan en cuatro principios, que puedes recordar por sus iniciales en inglés, POUR:
| Principio | Pregunta | Ejemplo de criterio |
|---|---|---|
| Perceptible | ¿Se puede percibir? | 1.4.3 Contraste mínimo |
| Operable | ¿Se puede manejar? | 2.1.1 Teclado |
| Comprensible | ¿Se entiende? | 3.3.1 Identificación de errores |
| Robusto | ¿Lo interpretan las tecnologías de apoyo? | 4.1.2 Nombre, rol, valor |
Cada criterio tiene un nivel: A, AA o AAA. El objetivo habitual de un proyecto es AA, y es el que seguiremos.
Prepara el proyecto
Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-24 -- --template react-ts
cd modulo-24
npm install
npm install react-router i18next react-i18next i18next-browser-languagedetector i18next-resources-to-backend
npm install -D axe-core @axe-core/playwright @playwright/test vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom @testing-library/dom
npx playwright install chromium
npm run dev
⚠️ Importante: Abre
index.htmly cambialang="en"porlang="es". La plantilla de Vite trae el inglés, y con eso el lector de pantalla leería tu texto en español con pronunciación inglesa. axe no lo detecta: solo comprueba que el atributo exista y sea válido, no que diga la verdad. Es el criterio 3.1.1, de nivel A.
Descarga la API de ejemplo y arráncala en otra terminal con node server.mjs. Queda en http://localhost:3000/api.
Crea src/api.ts, que usarán varios ejemplos:
export const API = 'http://localhost:3000/api'
export interface Empleado {
empId: number
nombre: string
apellidos: string
email: string
fechaAlta: string
oficio: string
salario: number
departamento: string
}
export interface Pagina<T> {
contenido: T[]
pagina: number
tamano: number
total: number
paginas: number
}
export async function buscarEmpleados(
texto: string,
senal?: AbortSignal,
): Promise<Pagina<Empleado>> {
const parametros = new URLSearchParams({ q: texto, tamano: '100' })
const respuesta = await fetch(`${API}/empleados?${parametros}`, { signal: senal })
if (!respuesta.ok) throw new Error(`Error ${respuesta.status}`)
return respuesta.json()
}
Cada ejemplo es un componente que puedes renderizar desde src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos y que iremos comentando:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 680px;
margin: 0 0 1rem;
}
h1,
h2 {
margin: 0 0 0.5rem;
color: #1a237e;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.15rem;
}
.fila {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
button,
input,
select {
font: inherit;
min-height: 2rem;
padding: 0.25rem 0.75rem;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #fff;
color: #283593;
}
button {
cursor: pointer;
}
button[aria-pressed='true'],
button[aria-selected='true'] {
background: #3f51b5;
color: #fff;
}
/* Indicador de foco: solo cuando el navegador cree que hace falta */
:focus-visible {
outline: 3px solid #ff7043;
outline-offset: 2px;
}
/* Oculto a la vista, pero presente para el lector de pantalla */
.solo-lector {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Enlace para saltar: se ve solo al recibir el foco */
.saltar {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
padding: 0.5rem 1rem;
background: #1a237e;
color: #fff;
border-radius: 6px;
transform: translateY(-300%);
}
.saltar:focus {
transform: none;
}
.campo {
display: grid;
gap: 0.2rem;
margin-block-end: 0.75rem;
}
.campo label {
font-weight: 600;
}
.ayuda {
color: #546e7a;
font-size: 0.9rem;
margin: 0;
}
.error {
color: #b71c1c;
font-size: 0.9rem;
margin: 0;
}
input[aria-invalid='true'] {
border-color: #b71c1c;
border-width: 2px;
}
.resumen-errores {
border: 2px solid #b71c1c;
border-radius: 8px;
padding: 0.5rem 1rem;
margin-block-end: 1rem;
}
table {
border-collapse: collapse;
width: 100%;
}
caption {
text-align: start;
font-weight: 600;
margin-block-end: 0.5rem;
}
th,
td {
padding: 0.35rem 0.5rem;
border-block-end: 1px solid #c5cae9;
text-align: start;
}
td.numero {
text-align: end;
font-variant-numeric: tabular-nums;
}
dialog {
border: 1px solid #3f51b5;
border-radius: 10px;
padding: 1.25rem;
max-width: 420px;
}
dialog::backdrop {
background: rgb(26 35 126 / 0.45);
}
[role='listbox'] {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
border: 1px solid #3f51b5;
border-radius: 6px;
max-width: 340px;
background: #fff;
}
[role='option'] {
padding: 0.35rem 0.6rem;
cursor: pointer;
}
[role='option'][aria-selected='true'] {
background: #3f51b5;
color: #fff;
}
[role='tablist'] {
display: flex;
gap: 0.25rem;
margin-block-end: 0.75rem;
}
/* Respeta a quien pide menos movimiento */
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
2. Cómo ve tu página una tecnología de apoyo
Un lector de pantalla no mira píxeles. El navegador construye, a partir del DOM y del CSS calculado, una estructura paralela llamada árbol de accesibilidad, y el sistema operativo se la entrega a la tecnología de apoyo.
Cada nodo de ese árbol tiene tres datos que importan. Memorízalos, porque casi todo el módulo gira en torno a ellos:
| Dato | Pregunta | Ejemplo |
|---|---|---|
| Rol | ¿Qué es? | botón, enlace, casilla, título |
| Nombre | ¿Cómo se llama? | “Guardar”, “Buscar por nombre” |
| Estado | ¿Cómo está ahora? | marcado, expandido, deshabilitado |
Figura 1. React termina en el DOM. El navegador deriva de ahí el árbol que recibe el lector de pantalla.
Por tanto, React no hace que una aplicación sea accesible ni inaccesible: decide qué DOM se genera. Ese es el motivo por el que una aplicación React puede ser excelente o desastrosa con la misma versión del framework.
Una página mala
Fíjate en este componente. A simple vista funciona: si lo abres con el ratón no notas nada raro.
import { useState } from 'react'
const LOGO =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' rx='8' fill='%233f51b5'/%3E%3C/svg%3E"
export default function PaginaMala() {
const [buscado, setBuscado] = useState(false)
return (
<div className="tarjeta">
<div className="fila">
<img src={LOGO} />
<h3>EMPRESA</h3>
</div>
<h5>Buscar empleados</h5>
<div className="fila">
<input placeholder="Nombre" />
<div
onClick={() => setBuscado(true)}
style={{ background: '#3f51b5', color: '#fff', padding: '4px 12px', borderRadius: 6 }}
>
Buscar
</div>
<button>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M3 3l10 10M13 3L3 13" stroke="#283593" strokeWidth="2" />
</svg>
</button>
</div>
<p style={{ color: '#b0bec5' }}>
{buscado ? 'Se han encontrado 24 empleados' : 'Escribe un nombre'}
</p>
<a href="/ayuda"></a>
</div>
)
}
Para ver qué recibe el lector de pantalla, Playwright (que conociste en el módulo 22) ofrece ariaSnapshot(), un volcado textual del árbol. Esta es la salida real de la página anterior:
- img
- heading "EMPRESA" [level=3]
- heading "Buscar empleados" [level=5]
- textbox "Nombre"
- text: Buscar
- button:
- img
- paragraph: Escribe un nombre
- link:
- /url: /ayuda
Léelo como lo haría alguien que no ve la pantalla:
img: hay una imagen, sin nombre. Muchos lectores dirán el nombre del fichero o, con una URLdata:como esta, una ristra sin sentido.text: Buscar: esto no es un botón, es texto. El lector no dirá que se pueda pulsar.button:sin nombre: hay un botón, pero nadie sabe para qué sirve. El lector dirá solo “botón”.link:sin nombre: un enlace que no dice a dónde va.- Los títulos saltan de nivel 3 a 5 y no hay ningún
h1. - No hay regiones (
main,banner): no se puede saltar al contenido.
Y esto es lo que pasa al recorrerla con la tecla Tab. La lista es el elemento activo en cada pulsación:
INPUT > BUTTON (sin nombre) > A (sin nombre) > BODY > INPUT
El div “Buscar” no aparece: quien usa teclado no puede lanzar la búsqueda.
La misma página, bien hecha
import { useState, type SubmitEvent } from 'react'
const LOGO =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' rx='8' fill='%233f51b5'/%3E%3C/svg%3E"
export default function PaginaBuena() {
const [texto, setTexto] = useState('')
const [buscado, setBuscado] = useState(false)
function alBuscar(evento: SubmitEvent<HTMLFormElement>) {
evento.preventDefault()
setBuscado(true)
}
return (
<>
<header className="fila">
<img src={LOGO} alt="" />
<p>EMPRESA</p>
</header>
<main className="tarjeta">
<h1>Buscar empleados</h1>
<form role="search" onSubmit={alBuscar} className="fila">
<label htmlFor="nombre">Nombre</label>
<input
id="nombre"
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
<button type="submit">Buscar</button>
<button type="button" aria-label="Limpiar la búsqueda" onClick={() => setTexto('')}>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
</form>
<p role="status">{buscado ? 'Se han encontrado 24 empleados' : ''}</p>
<a href="/ayuda">Ayuda sobre la búsqueda</a>
</main>
</>
)
}
Y su árbol real:
- banner:
- paragraph: EMPRESA
- main:
- heading "Buscar empleados" [level=1]
- search:
- text: Nombre
- textbox "Nombre"
- button "Buscar"
- button "Limpiar la búsqueda"
- status
- link "Ayuda sobre la búsqueda":
- /url: /ayuda
Con Tab: INPUT > Buscar > Limpiar la búsqueda > Ayuda sobre la búsqueda. Observa que casi no hay ARIA. La mayoría de las mejoras son HTML corriente: button, form, label, header, main, h1. Ese formulario, además, se envía con Intro sin escribir una línea de código de teclado.

Figura 2. Captura real de la página mala. Los recuadros rojos son los elementos que axe señala individualmente.

Figura 3. La versión corregida. Por fuera es parecida. Por dentro, es otra aplicación.
💻 Ejemplo: En tu navegador puedes ver este árbol sin instalar nada. En Chrome y Edge, abre las herramientas de desarrollo, selecciona un elemento y entra en la pestaña Accessibility. En Firefox existe una pestaña Accesibilidad con el árbol completo.
3. HTML primero, ARIA después
La primera regla de ARIA
ARIA (Accessible Rich Internet Applications) es un conjunto de atributos que modifican lo que aparece en el árbol de accesibilidad: role, aria-label, aria-expanded…
Lo esencial que debes saber es que ARIA solo cambia lo que se anuncia, no lo que hace el elemento. Un role="button" no añade foco, ni teclado, ni clic. La guía de prácticas del W3C lo resume en una frase, “No ARIA is better than Bad ARIA”, y compara ARIA con una especie de CSS para las tecnologías de apoyo: si mientes, el usuario recibe esa mentira.
Los datos lo reflejan. En el mismo informe de WebAIM, las páginas con ARIA tenían de media 59,1 errores detectados, frente a 42 en las que no lo usaban. Eso no prueba que ARIA sea la causa, porque esas páginas también son más complejas, pero sí demuestra que añadir ARIA no mejora nada por sí solo.
Compara lo que hace falta para imitar un botón con lo que cuesta usarlo:
// Imitación: rol, foco y dos teclas, todo a mano, y aún falta deshabilitarlo
<div
role="button"
tabIndex={0}
onClick={guardar}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
guardar()
}
}}
>
Guardar
</div>
// Lo mismo, y además funciona disabled, form y type="submit"
<button type="button" onClick={guardar}>Guardar</button>
Si haces una tabla mental rápida del HTML que ya resuelve el problema, te ahorrarás casi todo el ARIA:
| Necesitas | Usa | En lugar de |
|---|---|---|
| Una acción | <button> | <div onClick> |
| Ir a otra página | <a href> | <button onClick={navegar}> |
| Desplegar contenido | <details> y <summary> | Un desplegable a mano, si te vale su aspecto |
| Elegir uno entre pocos | <input type="radio"> en <fieldset> | Tarjetas clicables |
| Elegir uno entre muchos | <select> | Una lista a mano |
| Encender o apagar | <input type="checkbox"> | <div role="checkbox"> |
| Ventana modal | <dialog> | <div role="dialog"> y una trampa de foco |
| Regiones | <header>, <nav>, <main>, <footer>, <aside> | <div role="main"> |
| Progreso | <progress> | Una barra a mano |
⚠️ Importante: Un
<button>dentro de un<form>es por defectotype="submit". Un botón de “Cancelar” sintype="button"envía el formulario. Por eso, todos los botones de este módulo que no envían llevantype="button".
Regiones y títulos: el índice de tu página
Quien usa un lector de pantalla no lee de arriba abajo. Lo normal es que salte por títulos o por regiones, igual que tú ojeas una página. Con NVDA, por ejemplo, la tecla H va al siguiente título y D a la siguiente región.
| Elemento | Rol que recibe | Condición |
|---|---|---|
<main> | main | Uno por página |
<nav> | navigation | Si hay varios, dales nombre con aria-label |
<header> | banner | Solo si es hijo directo de body o de div, no de article ni section |
<footer> | contentinfo | Con la misma condición |
<aside> | complementary | |
<section> | region | Solo si tiene nombre |
<form> | form | Solo si tiene nombre |
<form role="search"> | search |
Los títulos forman un esquema, no un tamaño de letra. Debe haber un h1 por pantalla y no conviene saltar niveles. Si quieres un h2 que parezca más pequeño, cámbialo con CSS.
💡 Consejo: Un componente reutilizable no sabe en qué nivel de título acabará. Si tienes un componente
Tarjetacon un título dentro, pásale una prop para elegir la etiqueta (por ejemplocomo="h3") en lugar de fijar unh2. El módulo 13 explica cómo tipar esos componentes polimórficos.
ARIA en JSX
JSX escribe los atributos ARIA tal como en HTML, en minúsculas y con guion. Es la excepción a la regla del camelCase. Otros, en cambio, sí cambian:
| HTML | JSX |
|---|---|
aria-label, aria-expanded, role | Igual |
for | htmlFor |
tabindex | tabIndex |
autocomplete | autoComplete |
readonly | readOnly |
Los atributos ARIA son texto en el DOM, y eso cambia cómo se comportan los booleanos. Esta es la salida real de renderToStaticMarkup, idéntica en React 18.3.1, 19.0.9 y 19.3.1:
<button aria-expanded={false} aria-hidden={true} aria-label={undefined} />
// <button aria-expanded="false" aria-hidden="true"></button>
<input hidden={false} required={true} aria-invalid={true} aria-required={false} readOnly={false} />
// <input required="" aria-invalid="true" aria-required="false"/>
De aquí salen dos reglas prácticas:
aria-expanded={false}se escribe como"false", y es lo correcto: “contraído” es un estado distinto de “no se puede expandir”. Si no quieres el atributo, pasaundefined.- Los atributos booleanos de HTML (
hidden,required,readOnly,disabled) desaparecen cuando valenfalse.
Si te equivocas al escribirlo, React avisa en la consola pero lo pinta igualmente: aria-Label produce Unknown ARIA attribute aria-Label. Did you mean aria-label?, y ariaLabel produce Invalid ARIA attribute ariaLabel. TypeScript también te ayuda, porque los tipos de React limitan los valores de atributos como aria-sort o aria-live.
Cómo se calcula el nombre
Es la pregunta más frecuente: ¿qué dirá el lector para este control? El navegador prueba por orden y se queda con el primero que encuentra:
aria-labelledby: el texto del elemento o elementos cuyo id se cita.aria-label: el texto del atributo.- La forma nativa:
<label>para campos,altpara imágenes,<caption>para tablas. - El contenido, en los roles que lo admiten, como botones y enlaces.
- Último recurso:
titleoplaceholder.
⚠️ Importante: En la página mala, el árbol muestra
textbox "Nombre"gracias alplaceholder, y axe no protesta. Aun así, es un error de diseño: el texto desaparece al escribir, suele tener poco contraste y confunde a quien lo toma por un valor ya rellenado. Unplaceholdernunca sustituye a un<label>.
Los nombres funcionan bien con estas pautas:
// El contenido da nombre. No hace falta nada más
<button type="button">Guardar</button>
// Solo icono: el nombre va en aria-label y el icono se oculta
<button type="button" aria-label="Cerrar">
<svg aria-hidden="true">...</svg>
</button>
// Varios botones iguales: que cada uno diga sobre qué actúa
<button type="button" aria-label={`Borrar a ${persona.nombre}`}>Borrar</button>
// Imagen informativa: describe lo que aporta
<img src={foto} alt="Ana García, directora general" />
// Imagen decorativa: alt vacío, no alt ausente
<img src={adorno} alt="" />
⚠️ Importante: Si
aria-labelno contiene el texto visible, quien controla el ordenador por voz diciendo “pulsa Borrar” no podrá activarlo. Por eso el nombre es “Borrar a Ana García” y no “Eliminar empleado 100”. Conviene que el nombre accesible empiece por lo que se ve.
Ocultar también tiene sus matices, y conviene tenerlos claros:
| Técnica | ¿Se ve? | ¿Lo lee el lector? | ¿Recibe foco? |
|---|---|---|---|
| Normal | Sí | Sí | Sí |
hidden, display: none | No | No | No |
visibility: hidden | No | No | No |
Clase .solo-lector | No | Sí | Sí |
aria-hidden="true" | Sí | No | Sí, y eso es un error si contiene controles |
inert | Sí | No | No |
inert: apagar una zona completa 🔄 19.0
🔄 Cambia en React 19.0. En 18.3.1,
inert={true}no llega al DOM y React muestra un aviso, y en 19 el truco antiguoinert=""deja de funcionar. Si usas una versión anterior: escribeinert=""para activarlo yinert={undefined}para quitarlo, y añade un// @ts-expect-errorsi tus tipos no lo admiten.
inert hace que un elemento y todo su interior dejen de recibir clics y foco, y que desaparezcan del árbol de accesibilidad. Va bien con un panel lateral cerrado que siga en el DOM para animarse:
<aside inert={!abierto} className={abierto ? 'panel abierto' : 'panel'}>
<button type="button">Exportar</button>
</aside>
<div ...> | 18.3.1 | 19.0.9 y 19.3.1 |
|---|---|---|
inert={true} | <div></div> y aviso | <div inert=""> |
inert={false} | <div></div> | <div></div> |
inert="" | <div inert=""> | <div></div> y aviso |
4. Patrones con ARIA
Cuando el HTML se queda corto, no inventes: la guía ARIA Authoring Practices (https://www.w3.org/WAI/ARIA/apg/patterns/) describe, para cada componente, los roles y las teclas que los usuarios esperan.
Estados de un botón
| Atributo | Qué comunica | Ejemplo | El texto del botón |
|---|---|---|---|
aria-pressed | Conmutador, encendido o apagado | Favorito | No cambia |
aria-expanded | Abre o cierra otra zona | Desplegable | No cambia |
aria-checked con role="switch" | Interruptor | Avisos | No cambia |
aria-current | El elemento actual de un conjunto | Enlace de la página activa | No cambia |
Un error típico es cambiar el texto y además marcar el estado: aria-pressed="true" con el texto “Quitar de favoritos” se anuncia como algo parecido a “Quitar de favoritos, botón, pulsado”, que es confuso. O cambias el texto, o usas el estado.
Contenido desplegable
import { useId, useState } from 'react'
export default function Desplegable() {
const [abierto, setAbierto] = useState(false)
const idPanel = useId()
return (
<main className="tarjeta">
<h1>Ficha del departamento</h1>
<button
type="button"
aria-expanded={abierto}
aria-controls={idPanel}
onClick={() => setAbierto((a) => !a)}
>
Ver presupuesto
</button>
<div id={idPanel} hidden={!abierto}>
<p>INFORMÁTICA tiene un presupuesto anual asignado.</p>
</div>
</main>
)
}
Esta es la salida real del DOM con el panel cerrado:
<button type="button" aria-expanded="false" aria-controls="_r_0_">Ver presupuesto</button>
useId (ya lo viste en el módulo 08) genera identificadores únicos, también si el componente aparece varias veces en la página. No fijes ids a mano en un componente reutilizable, y no construyas nada a partir del formato del id, porque no está garantizado.
El botón funciona con Intro y con Espacio sin código adicional, y lo que se oculta con hidden sale también del árbol y del recorrido con Tab.
Regiones vivas: cuando algo cambia sin que el foco se mueva
Imagina que el usuario escribe en un buscador y la lista se actualiza. Quien ve la pantalla lo nota al momento. Quien usa un lector de pantalla está en el campo de texto y no oye nada: el foco no se ha movido, así que no sabe si hay 3 resultados o ninguno. El criterio 4.1.3 pide resolverlo.
Una región viva es una zona que el lector vigila: cuando su contenido cambia, lo lee sin mover el foco.
| Atributo | Comportamiento | Uso |
|---|---|---|
role="status" | Espera a que termine lo que está leyendo | Resultados, guardado correcto |
role="alert" | Interrumpe | Errores que requieren atención |
aria-live="polite" | Como status, sin rol | Zonas genéricas |
aria-live="assertive" | Como alert, sin rol | Muy rara vez |
aria-atomic="true" | Lee la región entera, no solo lo cambiado | Marcadores como “3 de 10” |
aria-busy="true" | Pide esperar mientras se actualiza | Tablas que se recargan |
El error que más veces se comete con ellas se debe a cómo funciona React:
// MAL: la región nace a la vez que su mensaje, y muchos lectores no lo dicen
{mensaje && <div role="status">{mensaje}</div>}
// BIEN: la región existe desde el principio y solo cambia su contenido
<div role="status">{mensaje}</div>
El lector solo detecta cambios en una región que ya conocía. Por eso el mensaje se añade dentro de un contenedor que ya estaba.
En una aplicación real conviene tener un único anunciador global, así cualquier componente puede decir algo sin montar su propia región.
Anunciador global 🆕 19.0
🆕 Desde React 19.0.
<Contexto value>yuse(Contexto)no existen en 18.3. Si usas una versión anterior: escribe<Contexto.Provider value>yuseContext(Contexto), que hacen lo mismo. El resto del anunciador no cambia.
// src/ej/Anunciador.tsx
import { createContext, use, useCallback, useRef, useState, type ReactNode } from 'react'
type Modo = 'educado' | 'urgente'
type Anunciar = (mensaje: string, modo?: Modo) => void
const ContextoAnunciador = createContext<Anunciar | null>(null)
export function ProveedorAnunciador({ children }: { children: ReactNode }) {
const [educado, setEducado] = useState('')
const [urgente, setUrgente] = useState('')
const temporizador = useRef<number>(undefined)
const anunciar = useCallback<Anunciar>((mensaje, modo = 'educado') => {
const fijar = modo === 'urgente' ? setUrgente : setEducado
// Se vacía primero: repetir el mismo texto no cambiaría el DOM
// y el lector de pantalla no diría nada
fijar('')
window.clearTimeout(temporizador.current)
temporizador.current = window.setTimeout(() => fijar(mensaje), 100)
}, [])
return (
<ContextoAnunciador value={anunciar}>
{children}
{/* Las regiones existen desde el principio, vacías */}
<div role="status" className="solo-lector">{educado}</div>
<div role="alert" className="solo-lector">{urgente}</div>
</ContextoAnunciador>
)
}
export function useAnunciar(): Anunciar {
const anunciar = use(ContextoAnunciador)
if (!anunciar) throw new Error('useAnunciar exige <ProveedorAnunciador>')
return anunciar
}
Y un buscador contra la API que lo usa:
// src/ej/BuscadorAnunciado.tsx
import { useEffect, useState } from 'react'
import { buscarEmpleados, type Empleado } from '../api'
import { ProveedorAnunciador, useAnunciar } from './Anunciador'
function Resultados() {
const [texto, setTexto] = useState('')
const [empleados, setEmpleados] = useState<Empleado[]>([])
const anunciar = useAnunciar()
useEffect(() => {
const control = new AbortController()
const espera = setTimeout(async () => {
try {
const pagina = await buscarEmpleados(texto, control.signal)
setEmpleados(pagina.contenido)
anunciar(pagina.total === 1 ? '1 empleado encontrado' : `${pagina.total} empleados encontrados`)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
anunciar('No se ha podido hacer la búsqueda', 'urgente')
}
}, 300) // espera a que deje de teclear
return () => {
clearTimeout(espera)
control.abort()
}
}, [texto, anunciar])
return (
<section className="tarjeta">
<h1>Empleados</h1>
<div className="campo">
<label htmlFor="busqueda">Buscar por nombre</label>
<input
id="busqueda"
type="search"
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
</div>
<ul>
{empleados.slice(0, 5).map((e) => (
<li key={e.empId}>{e.nombre} {e.apellidos}</li>
))}
</ul>
</section>
)
}
export default function BuscadorAnunciado() {
return (
<ProveedorAnunciador>
<main>
<Resultados />
</main>
</ProveedorAnunciador>
)
}
La pausa de 300 ms también ayuda al usuario: sin ella, cada letra provocaría un anuncio distinto. Las condiciones de carrera con AbortController se explican en el módulo 09.
Para comprobar el truco de vaciar la región, he observado con un MutationObserver lo que le pasa al nodo al repetir una búsqueda que devuelve el mismo total. Esta es la secuencia real de contenidos:
'1 empleado encontrado', '', '1 empleado encontrado'
El texto se vacía y vuelve a aparecer, y por eso se detecta como cambio.
5. El foco
El foco es el “aquí estoy” de quien usa teclado o lector de pantalla. En una página clásica, cada navegación lo reinicia. En una aplicación React lo que hay en pantalla cambia sin recargar nada, de modo que eres tú quien responde de dónde queda el foco. La regla es simple: después de cualquier cambio, el foco debe estar en un lugar con sentido.
El indicador de foco
/* NUNCA hagas esto sin poner otra cosa */
button:focus {
outline: none;
}
Esa regla, muy copiada, deja a quien usa teclado sin saber dónde está. Lo adecuado es :focus-visible, que ya tienes en el CSS del módulo: el navegador muestra el indicador cuando hace falta (al usar teclado) y no al hacer clic con el ratón en un botón. El indicador debe contrastar al menos 3:1 con el fondo (criterio 1.4.11).
tabIndex en tres valores
| Valor | Efecto | Uso |
|---|---|---|
| Sin atributo | Solo lo reciben los elementos interactivos | El caso normal |
tabIndex={0} | Entra en el orden de Tab, en su posición del DOM | Elementos propios, paneles con scroll |
tabIndex={-1} | No entra con Tab, pero admite .focus() | Títulos, contenedores, tabindex itinerante |
tabIndex={1} o más | Fuerza el orden | Nunca: rompe el orden con todo lo demás |
El orden de Tab es el del DOM. Si con flex-direction: row-reverse o con order cambias el orden visual, el foco saltará de forma incoherente: cambia el orden en el JSX.
Saltar al contenido y cambios de ruta 🆕 19.0
🆕 Desde React 19.0.
<title>dentro de un componente, que React lleva al<head>, no existe en 18.3. Si usas una versión anterior: usa un efecto condocument.title = titulo. Ten en cuenta también que React Router 8 exige React 19.2.7 o superior.
En una aplicación con rutas ocurren dos problemas. El primero es que quien usa teclado debe atravesar todo el menú en cada página. Se soluciona con un enlace al principio que salta al contenido, visible solo al recibir el foco.
El segundo es menos evidente. Al pulsar un enlace del menú, React cambia el contenido, pero no se anuncia nada: el foco sigue en el enlace y la persona no sabe si ha ocurrido algo. Con una carga de página normal el lector leería el título nuevo. Hay que reproducirlo:
// src/ej/RutasAccesibles.tsx
import { useEffect, useRef } from 'react'
import { createBrowserRouter, NavLink, Outlet, RouterProvider, useLocation } from 'react-router'
function Diseno() {
const { pathname } = useLocation()
const principal = useRef<HTMLElement>(null)
const primeraCarga = useRef(true)
useEffect(() => {
if (primeraCarga.current) {
primeraCarga.current = false // al cargar la página, el foco se queda donde está
return
}
principal.current?.querySelector('h1')?.focus()
}, [pathname])
return (
<>
<a href="#principal" className="saltar">Saltar al contenido</a>
<header>
<nav aria-label="Principal" className="fila">
<NavLink to="/rutas" end>Inicio</NavLink>
<NavLink to="/rutas/empleados">Empleados</NavLink>
<NavLink to="/rutas/pedidos">Pedidos</NavLink>
</nav>
</header>
<main id="principal" ref={principal} tabIndex={-1} className="tarjeta">
<Outlet />
</main>
</>
)
}
function Pagina({ titulo }: { titulo: string }) {
return (
<>
<title>{`${titulo} | EMPRESA`}</title>
<h1 tabIndex={-1}>{titulo}</h1>
<p>Contenido de la página {titulo}.</p>
</>
)
}
const enrutador = createBrowserRouter([
{
path: '/rutas',
element: <Diseno />,
children: [
{ index: true, element: <Pagina titulo="Inicio" /> },
{ path: 'empleados', element: <Pagina titulo="Empleados" /> },
{ path: 'pedidos', element: <Pagina titulo="Pedidos" /> },
],
},
])
export default function RutasAccesibles() {
return <RouterProvider router={enrutador} />
}
Para hacerlo funcionar, en App.tsx basta con return <RutasAccesibles />, y debes abrir http://localhost:5173/rutas. React Router se explica en el módulo 18.
Lo que se ha comprobado en Chromium:
- La primera pulsación de Tab llega a “Saltar al contenido”, e Intro lleva el foco a
<main>. - Al navegar con Intro a Empleados, el foco pasa al
h1, de modo que el lector dirá “Empleados”. document.titlecambia aEmpleados | EMPRESA.NavLinkañadearia-current="page"al enlace activo sin que hagas nada.

Figura 4. El enlace para saltar aparece solo cuando recibe el foco con Tab.
¿Por qué tabIndex={-1} en el h1 y en <main>? Porque esos elementos normalmente no reciben foco, y sin este atributo .focus() no haría nada. El -1 permite enfocarlos por código sin añadir una parada de Tab. Y la bandera primeraCarga evita robar el foco al abrir la aplicación.
💡 Consejo: Cuando un
h1recibe el foco,:focus-visiblepuede dibujarle un recuadro. Con el ratón no suele aparecer, pero con teclado sí, y es correcto dejarlo. Si te molesta en el diseño, usah1:focus { outline: none; }solo en estos títulos, que no son controles.
Diálogos con <dialog>
Un diálogo modal hecho a mano necesita trampa de foco, Esc, marcar el fondo como inerte, devolver el foco al cerrar y gestionar la capa superior. El elemento <dialog> con showModal() lo trae todo hecho.
// src/ej/Dialogo.tsx
import { useEffect, useId, useRef, type ReactNode } from 'react'
interface DialogoProps {
abierto: boolean
titulo: string
alCerrar: () => void
children: ReactNode
}
export function Dialogo({ abierto, titulo, alCerrar, children }: DialogoProps) {
const dialogo = useRef<HTMLDialogElement>(null)
const idTitulo = useId()
useEffect(() => {
const nodo = dialogo.current
if (!nodo) return
if (abierto && !nodo.open) nodo.showModal()
if (!abierto && nodo.open) nodo.close()
}, [abierto])
return (
// onClose salta también con Esc: así el estado de React no se desincroniza
<dialog ref={dialogo} aria-labelledby={idTitulo} onClose={alCerrar}>
<h2 id={idTitulo}>{titulo}</h2>
{children}
</dialog>
)
}
El puente con React es un efecto que traduce el estado abierto a showModal() y close(). El detalle más importante es onClose: al pulsar Esc, es el navegador quien cierra el diálogo, y React se enteraría tarde sin ese evento. Con él, el estado vuelve a estar sincronizado.
Lo comprobado con Playwright en Chromium, abriendo el diálogo con Intro desde un botón:
| Acción | Resultado real |
|---|---|
| Abrir | El foco va a Cancelar, el primer control |
| Tab repetido | Borrar > BODY > Cancelar > Borrar. Nunca sale a la página de detrás |
| Esc | Se cierra y el foco vuelve a “Borrar a Luis Martínez” |
| Cancelar | Igual que Esc |
El BODY del recorrido corresponde al momento en que el foco sale a la interfaz del propio navegador, que es lo que corresponde: el usuario debe poder llegar a la barra de direcciones.
Borrar sin perder al usuario
El navegador devuelve el foco al botón de origen. Pero ¿y si ese botón es justo lo que acabas de borrar? Un element.remove() sobre el elemento enfocado da este resultado real:
Foco tras eliminar el elemento enfocado: BODY
Para quien usa teclado, eso significa volver al principio de la página. Debes decidir tú el destino: la fila siguiente, la anterior si era la última, y el título si la lista queda vacía.
Figura 5. La vuelta automática del foco solo sirve si el elemento de origen sigue existiendo.
// src/ej/ListaBorrable.tsx
import { useRef, useState } from 'react'
import { Dialogo } from './Dialogo'
interface Persona {
id: number
nombre: string
}
const INICIALES: Persona[] = [
{ id: 100, nombre: 'Ana García' },
{ id: 101, nombre: 'Luis Martínez' },
{ id: 102, nombre: 'Marta Sánchez' },
]
export default function ListaBorrable() {
const [personas, setPersonas] = useState(INICIALES)
const [aBorrar, setABorrar] = useState<Persona | null>(null)
const botones = useRef(new Map<number, HTMLButtonElement>())
const titulo = useRef<HTMLHeadingElement>(null)
// Quién debe recibir el foco cuando el diálogo acabe de cerrarse
const siguienteFoco = useRef<number | 'titulo' | null>(null)
function confirmar() {
if (!aBorrar) return
const posicion = personas.findIndex((p) => p.id === aBorrar.id)
const restantes = personas.filter((p) => p.id !== aBorrar.id)
const vecina = restantes[posicion] ?? restantes[posicion - 1]
siguienteFoco.current = vecina ? vecina.id : 'titulo'
setPersonas(restantes)
setABorrar(null)
}
function alCerrarDialogo() {
setABorrar(null)
const destino = siguienteFoco.current
siguienteFoco.current = null
if (destino === 'titulo') titulo.current?.focus()
else if (destino !== null) botones.current.get(destino)?.focus()
}
return (
<main className="tarjeta">
<h1 ref={titulo} tabIndex={-1}>Plantilla</h1>
<ul>
{personas.map((p) => (
<li key={p.id} className="fila">
{p.nombre}
<button
type="button"
ref={(nodo) => {
if (nodo) botones.current.set(p.id, nodo)
else botones.current.delete(p.id)
}}
aria-label={`Borrar a ${p.nombre}`}
onClick={() => setABorrar(p)}
>
Borrar
</button>
</li>
))}
</ul>
<Dialogo abierto={aBorrar !== null} titulo="Confirmar el borrado" alCerrar={alCerrarDialogo}>
<p>¿Seguro que quieres borrar a {aBorrar?.nombre}?</p>
<div className="fila">
<button type="button" onClick={() => setABorrar(null)}>Cancelar</button>
<button type="button" onClick={confirmar}>Borrar</button>
</div>
</Dialogo>
</main>
)
}
La parte delicada es el momento. Si llamaras a .focus() dentro de confirmar, el diálogo aún estaría abierto y el resto de la página, inerte. Por eso se guarda el destino en una referencia y se aplica en alCerrarDialogo, que se ejecuta cuando el navegador ya ha cerrado. Lo he probado borrando a Luis (foco a Marta), luego a Marta (foco a Ana, porque era la última) y luego a Ana (foco al título).
Los botones se guardan en un Map con una función ref, como en el módulo 10.

Figura 6. El navegador pone la capa superior, el fondo (::backdrop) y la zona inerte.
autoFocus no funciona dentro de <dialog>
Por defecto, el foco va al primer control. En un borrado eso es lo prudente, porque el botón por defecto es Cancelar. Pero si quieres que empiece en otro control, lo natural sería escribir autoFocus, y no funciona. Se ha comprobado con dos botones y autoFocus en el segundo: al abrir, el foco fue a Cancelar, y el DOM real era este:
<button type="button">Aceptar a</button>
React no escribe el atributo autofocus en el navegador: llama a .focus() al montar el nodo. Y al montar, el diálogo aún está cerrado. Después showModal() busca un atributo que no existe. La solución que sí se ha comprobado es llamar tú a focus() justo después de showModal(), en el mismo efecto:
nodo.showModal()
botonAceptar.current?.focus()
Enfocar un grupo con Fragment Refs 🆕 19.3
🆕 Desde React 19.3.
<Fragment ref>no existe en React 19.2 ni anteriores. Si usas una versión anterior: pon unarefen un contenedor real y busca dentro conquerySelector('input, select, textarea, button, a[href]')?.focus().
Un FragmentInstance tiene focus(), que lleva el foco al primer elemento que pueda recibirlo dentro del fragmento, y focusLast(), sin necesidad de un div envoltorio ni de saber qué hay dentro:
import { Fragment, useRef, useState, type FragmentInstance } from 'react'
import type {} from 'react-dom' // trae los métodos de FragmentInstance a TypeScript
export default function GrupoFoco() {
const [editando, setEditando] = useState(false)
const grupo = useRef<FragmentInstance>(null)
function comenzarEdicion() {
setEditando(true)
// requestAnimationFrame: esperamos a que los campos estén en el DOM
requestAnimationFrame(() => grupo.current?.focus())
}
return (
<main className="tarjeta">
<h1>Datos de contacto</h1>
<button type="button" onClick={comenzarEdicion}>Editar</button>
{editando && (
<Fragment ref={grupo}>
<p>Modifica los datos:</p>
<div className="campo">
<label htmlFor="correo">Correo</label>
<input id="correo" type="email" autoComplete="email" />
</div>
<div className="campo">
<label htmlFor="telefono">Teléfono</label>
<input id="telefono" type="tel" autoComplete="tel" />
</div>
</Fragment>
)}
</main>
)
}
Al pulsar Editar, el foco pasa al campo Correo: el párrafo se salta porque no es enfocable. Las referencias a fragmentos se explican en el módulo 10.
6. Componentes de teclado
Todo lo que se pueda hacer con el ratón debe poder hacerse con teclado (criterio 2.1.1, nivel A). Las convenciones son estas:
| Tecla | Efecto esperado |
|---|---|
| Tab, Mayús+Tab | Ir al siguiente o anterior control |
| Intro | Activar botones y enlaces, enviar formularios |
| Espacio | Activar botones, marcar casillas |
| Flechas | Moverse dentro de un grupo: pestañas, radios, menús, listas |
| Esc | Cerrar lo que se ha abierto |
| Inicio, Fin | Ir al primero o al último de un grupo |
Tabindex itinerante: pestañas
Un grupo de 3 pestañas o un menú de 20 opciones no debe costar 20 pulsaciones de Tab. La convención es que Tab entra en el grupo y sale de él, y las flechas se mueven por dentro. Se consigue con el roving tabindex, o tabindex itinerante: solo un elemento del grupo tiene tabIndex={0} y los demás tabIndex={-1}.
Figura 7. Con la flecha, tanto el foco como el tabIndex={0} se trasladan a la pestaña siguiente.
import { useId, useRef, useState, type KeyboardEvent } from 'react'
const PESTANAS = [
{ titulo: 'Datos', contenido: 'Nombre, oficio y departamento del empleado.' },
{ titulo: 'Salario', contenido: 'Salario base y comisión.' },
{ titulo: 'Proyectos', contenido: 'Proyectos en los que participa.' },
]
export default function Pestanas() {
const [activa, setActiva] = useState(0)
const botones = useRef<(HTMLButtonElement | null)[]>([])
const base = useId()
function activar(indice: number) {
setActiva(indice)
botones.current[indice]?.focus()
}
function alPulsarTecla(evento: KeyboardEvent<HTMLDivElement>) {
const ultima = PESTANAS.length - 1
const destinos: Record<string, number> = {
ArrowRight: activa === ultima ? 0 : activa + 1,
ArrowLeft: activa === 0 ? ultima : activa - 1,
Home: 0,
End: ultima,
}
const destino = destinos[evento.key]
if (destino === undefined) return
evento.preventDefault() // Inicio y Fin no deben desplazar la página
activar(destino)
}
return (
<main className="tarjeta">
<h1 id={`${base}-titulo`}>Ficha del empleado</h1>
<div role="tablist" aria-labelledby={`${base}-titulo`} onKeyDown={alPulsarTecla}>
{PESTANAS.map((pestana, i) => (
<button
key={pestana.titulo}
ref={(nodo) => {
botones.current[i] = nodo
}}
type="button"
role="tab"
id={`${base}-tab-${i}`}
aria-selected={i === activa}
aria-controls={`${base}-panel-${i}`}
tabIndex={i === activa ? 0 : -1}
onClick={() => activar(i)}
>
{pestana.titulo}
</button>
))}
</div>
{PESTANAS.map((pestana, i) => (
<div
key={pestana.titulo}
role="tabpanel"
id={`${base}-panel-${i}`}
aria-labelledby={`${base}-tab-${i}`}
tabIndex={0}
hidden={i !== activa}
>
{pestana.contenido}
</div>
))}
</main>
)
}
Puntos clave del patrón:
role="tablist",role="tab"yrole="tabpanel"se relacionan conaria-controlsyaria-labelledby.aria-selectedindica cuál es la activa.- El estado guarda el índice, y la
reflleva el foco. React decide eltabIndexen cada render. evento.preventDefault()solo se ejecuta si la tecla es nuestra. Si lo llamaras siempre, Tab dejaría de funcionar dentro del grupo.- El panel lleva
tabIndex={0}porque en este ejemplo no tiene elementos enfocables. Si contiene alguno, puedes quitarlo.
Esto es lo que se obtuvo con Playwright al pulsar las teclas reales: Tab entra en Datos, la flecha derecha pasa a Salario y abre su panel, Fin va a Proyectos, la flecha derecha da la vuelta a Datos, y Tab desde la pestaña va al panel, sin pasar por las otras. El árbol final:
- heading "Ficha del empleado" [level=1]
- tablist "Ficha del empleado":
- tab "Datos"
- tab "Salario"
- tab "Proyectos" [selected]
- tabpanel "Proyectos": Proyectos en los que participa.

Figura 8. Pestañas tras recorrerlas solo con teclado. El borde naranja es :focus-visible.
💡 Consejo: Aquí, al mover el foco se activa la pestaña. Es lo recomendado si cambiar de panel es instantáneo. Si cada pestaña tiene que ir a la API, usa activación manual: la flecha solo mueve el foco y hay que pulsar Intro para cambiar. Así no se lanza una petición por cada flecha.
Combobox: el foco se queda, la opción activa se mueve
En un buscador con sugerencias, el foco tiene que quedarse en el campo para poder seguir escribiendo. Entonces, ¿cómo se indica qué opción está activa? Con aria-activedescendant: el campo declara qué elemento de la lista es el activo, y el lector lo anuncia como si tuviera el foco.
import { useEffect, useId, useState, type KeyboardEvent } from 'react'
import { buscarEmpleados, type Empleado } from '../api'
export default function Combobox() {
const [texto, setTexto] = useState('')
const [opciones, setOpciones] = useState<Empleado[]>([])
const [abierto, setAbierto] = useState(false)
const [activa, setActiva] = useState(-1)
const [elegido, setElegido] = useState<Empleado | null>(null)
const id = useId()
useEffect(() => {
if (texto.length < 2) return
const control = new AbortController()
buscarEmpleados(texto, control.signal)
.then((pagina) => {
setOpciones(pagina.contenido.slice(0, 6))
setActiva(-1)
})
.catch(() => {}) // la petición abortada no es un error
return () => control.abort()
}, [texto])
const visibles = abierto && texto.length >= 2 ? opciones : []
function elegir(empleado: Empleado) {
setElegido(empleado)
setTexto(`${empleado.nombre} ${empleado.apellidos}`)
setAbierto(false)
}
function alPulsarTecla(evento: KeyboardEvent<HTMLInputElement>) {
if (evento.key === 'ArrowDown') {
evento.preventDefault()
setAbierto(true)
if (visibles.length) setActiva((a) => (a + 1) % visibles.length)
} else if (evento.key === 'ArrowUp') {
evento.preventDefault()
if (visibles.length) setActiva((a) => (a <= 0 ? visibles.length - 1 : a - 1))
} else if (evento.key === 'Enter' && activa >= 0 && visibles[activa]) {
evento.preventDefault()
elegir(visibles[activa])
} else if (evento.key === 'Escape') {
setAbierto(false)
}
}
return (
<main className="tarjeta">
<h1>Asignar responsable</h1>
<div className="campo">
<label htmlFor={`${id}-entrada`}>Empleado</label>
<input
id={`${id}-entrada`}
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={visibles.length > 0}
aria-controls={`${id}-lista`}
aria-activedescendant={activa >= 0 ? `${id}-opcion-${activa}` : undefined}
value={texto}
onChange={(e) => {
setTexto(e.target.value)
setElegido(null)
setAbierto(true)
}}
onKeyDown={alPulsarTecla}
onBlur={() => setAbierto(false)}
/>
</div>
<ul id={`${id}-lista`} role="listbox" aria-label="Empleados" hidden={visibles.length === 0}>
{visibles.map((e, i) => (
<li
key={e.empId}
id={`${id}-opcion-${i}`}
role="option"
aria-selected={i === activa}
// mousedown y no click: el clic llegaría después del blur
onMouseDown={(evento) => {
evento.preventDefault()
elegir(e)
}}
>
{e.nombre} {e.apellidos}
</li>
))}
</ul>
<p role="status" className="solo-lector">
{visibles.length > 0 ? `${visibles.length} resultados disponibles` : ''}
</p>
{elegido && <p>Responsable: {elegido.nombre} ({elegido.oficio})</p>}
</main>
)
}

Figura 9. La opción resaltada es la de aria-activedescendant. El foco sigue en el campo de texto.
Un par de decisiones merecen atención:
onMouseDownconpreventDefaulten las opciones. Si fueraonClick, elonBlurdel campo cerraría la lista antes de que llegara el clic.hiddenen la lista cuando no hay resultados, en lugar de quitarla:aria-controlsdebe citar un id que exista.aria-selectedindica la opción activa dentro de la lista. Existe en cadaoption.- La lista se abre al escribir, Intro elige, Esc cierra y el campo sigue manteniendo el foco. Comprobado con teclas reales contra la API, y axe no encuentra infracciones con la lista abierta.
⚠️ Importante: Un combobox completo tiene muchos detalles: navegación con Inicio y Fin, comportamiento en pantallas táctiles, lectores con soporte desigual. Lo has hecho a mano para entender el mecanismo. En producción, plantéate primero si te basta con
<input list>y<datalist>, o usa una biblioteca ya probada como React Aria.
7. Formularios accesibles
El módulo 08 ya explicó las bases. Aquí vamos a cerrar el ciclo completo con un error real que devuelve la API.
| Necesidad | Solución |
|---|---|
| Nombre del campo | <label htmlFor> asociado con useId |
| Instrucciones | aria-describedby hacia un texto visible |
| Obligatorio | required, y un texto que explique qué significa el asterisco |
| Error | aria-invalid y aria-describedby hacia el mensaje |
| Agrupar radios o casillas | <fieldset> y <legend> |
| Autorrellenar datos personales | autoComplete con su valor estándar |
| Tras un envío fallido | Foco en el primer campo con error |
| Tras un envío correcto | Un mensaje en una región viva |
// src/ej/FormularioEmpleado.tsx
import { useEffect, useId, useRef, useState, type SubmitEvent } from 'react'
import { API } from '../api'
type Errores = Record<string, string>
interface CampoProps {
nombre: string
etiqueta: string
ayuda?: string
error?: string
tipo?: string
autoCompletar?: string
obligatorio?: boolean
}
function Campo({ nombre, etiqueta, ayuda, error, tipo = 'text', autoCompletar, obligatorio }: CampoProps) {
const id = useId()
const idAyuda = `${id}-ayuda`
const idError = `${id}-error`
// Solo se citan los ids que existen en este momento
const descritoPor = [ayuda && idAyuda, error && idError].filter(Boolean).join(' ')
return (
<div className="campo">
<label htmlFor={id}>
{etiqueta}
{obligatorio && <span aria-hidden="true"> *</span>}
</label>
<input
id={id}
name={nombre}
type={tipo}
autoComplete={autoCompletar}
required={obligatorio}
aria-invalid={error ? true : undefined}
aria-describedby={descritoPor || undefined}
/>
{ayuda && <p id={idAyuda} className="ayuda">{ayuda}</p>}
{error && <p id={idError} className="error">Error: {error}</p>}
</div>
)
}
async function iniciarSesion(): Promise<string> {
const respuesta = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
})
const datos: { token: string } = await respuesta.json()
return datos.token
}
export default function FormularioEmpleado() {
const [errores, setErrores] = useState<Errores>({})
const [enviando, setEnviando] = useState(false)
const [creado, setCreado] = useState('')
const formulario = useRef<HTMLFormElement>(null)
useEffect(() => {
// Tras cada intento fallido, el foco va al primer campo con error
formulario.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}, [errores])
async function alEnviar(evento: SubmitEvent<HTMLFormElement>) {
evento.preventDefault()
const form = evento.currentTarget
const datos = Object.fromEntries(new FormData(form))
setEnviando(true)
setCreado('')
try {
const token = await iniciarSesion()
const respuesta = await fetch(`${API}/empleados`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ ...datos, salario: Number(datos.salario) }),
})
if (respuesta.status === 422) {
const cuerpo: { detalles: Errores } = await respuesta.json()
setErrores(cuerpo.detalles)
return
}
if (!respuesta.ok) throw new Error(`Error ${respuesta.status}`)
setErrores({})
setCreado(`Empleado ${datos.nombre} creado correctamente`)
form.reset()
} catch {
setErrores({ general: 'No se ha podido guardar. Inténtalo de nuevo.' })
} finally {
setEnviando(false)
}
}
const lista = Object.entries(errores)
return (
<main className="tarjeta">
<h1>Nuevo empleado</h1>
<p className="ayuda">Los campos marcados con * son obligatorios.</p>
{/* noValidate: los mensajes los damos nosotros, no el navegador */}
<form ref={formulario} onSubmit={alEnviar} noValidate>
<div role="alert">
{lista.length > 0 && (
<div className="resumen-errores">
<p>
{lista.length === 1
? 'Hay 1 error en el formulario:'
: `Hay ${lista.length} errores en el formulario:`}
</p>
<ul>
{lista.map(([campo, mensaje]) => <li key={campo}>{mensaje}</li>)}
</ul>
</div>
)}
</div>
<Campo nombre="nombre" etiqueta="Nombre" obligatorio autoCompletar="given-name" error={errores.nombre} />
<Campo nombre="apellidos" etiqueta="Apellidos" obligatorio autoCompletar="family-name" error={errores.apellidos} />
<Campo
nombre="email"
etiqueta="Correo electrónico"
tipo="email"
obligatorio
autoCompletar="email"
ayuda="Usa el correo corporativo."
error={errores.email}
/>
<Campo nombre="oficio" etiqueta="Oficio" obligatorio error={errores.oficio} />
<Campo nombre="salario" etiqueta="Salario mensual en euros" tipo="number" obligatorio error={errores.salario} />
<button type="submit" disabled={enviando}>
{enviando ? 'Guardando…' : 'Guardar'}
</button>
</form>
<p role="status">{creado}</p>
</main>
)
}
💡 Consejo: Este código usa el tipo
SubmitEventporque@types/react19.3 marcaFormEventcomo obsoleto. Con tipos anteriores, escribeFormEvent<HTMLFormElement>.
Al enviarlo vacío, la API devuelve el 422 con detalles, y el formulario lo presenta así:

Figura 10. Respuesta real a un envío vacío: resumen arriba, error junto a cada campo y foco en el primero.
Y esto recibe el lector. Fíjate en que el rol alert no pierde nada al estar vacío al principio:
- alert:
- paragraph: "Hay 5 errores en el formulario:"
- list:
- listitem: El nombre es obligatorio
- listitem: Los apellidos son obligatorios
- listitem: El email no es válido
- listitem: El oficio es obligatorio
- listitem: El salario debe ser mayor que cero
- text: Nombre
- textbox "Nombre" [invalid]
- paragraph: "Error: El nombre es obligatorio"
Con Playwright se puede leer la descripción accesible de cada campo, que es lo que el lector dice tras el nombre. En el campo de correo vale exactamente esto:
Usa el correo corporativo. Error: El email no es válido
Decisiones de diseño:
- El error tiene un texto, no solo color. La palabra “Error:” evita depender del rojo (criterio 1.4.1). El borde más grueso de
aria-invalidtambién ayuda. aria-invalidsolo si hay error. Se pasaundefinedcuando no lo hay, para que no aparezca.aria-describedbysolo cita ids que existen. Por eso se construye según lo que haya.- El
role="alert"está siempre montado. Su contenido aparece después. - El asterisco lleva
aria-hidden. Si no, el lector diría “Nombre asterisco”.requiredya comunica que es obligatorio. - No deshabilites el botón Guardar para evitar errores. Un botón deshabilitado no recibe foco ni explica qué falta. Aquí solo se deshabilita mientras se envía.
- El foco y el aviso se combinan. El lector dice el resumen, y el foco deja al usuario en el sitio para corregir.
Lo he comprobado en los tres casos: 5 errores con el formulario vacío; 1 error con foco en el correo cuando solo ese está mal; y, con datos correctos, empleado creado, el resumen vacío y el status diciendo “Empleado Eva creado correctamente”.
💡 Consejo: Con React Hook Form (módulo 21),
setFocusyshouldFocusErrorhacen buena parte de esto, pero elaria-invalidy elaria-describedbysiguen siendo tarea tuya.
8. Lo que se ve: contraste, color, movimiento y tamaño
| Criterio | Nivel | Requisito |
|---|---|---|
| 1.4.3 Contraste mínimo | AA | 4.5:1 en texto normal, 3:1 en texto grande |
| 1.4.11 Contraste sin texto | AA | 3:1 en iconos y bordes de controles |
| 1.4.1 Uso del color | A | El color no puede ser la única señal |
| 2.5.8 Tamaño del objetivo | AA | 24 por 24 píxeles CSS, salvo excepciones como el espacio de separación o el texto en línea |
| 2.4.7 Foco visible | AA | El indicador de foco debe verse |
| 2.4.11 Foco no oculto | AA | Un elemento fijo no debe tapar por completo el elemento con foco |
El punto 2.4.11 explica un error habitual: una cabecera fija que tapa el control al que acabas de llegar con Tab. scroll-padding-top en html, con la altura de la cabecera, lo resuelve.
Contraste. Es el fallo más frecuente de la web. En la página mala, axe calcula que #b0bec5 sobre blanco no llega al mínimo, y la herramienta de color de las DevTools te indica cuánto tienes que oscurecerlo.
Movimiento. Algunas personas sufren mareos con animaciones grandes. El sistema operativo tiene una preferencia que CSS y JavaScript pueden leer:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
const reducir = window.matchMedia('(prefers-reduced-motion: reduce)').matches
Para reaccionar a los cambios de esa preferencia dentro de un componente, usa el hook useMediaQuery del módulo 12. Las View Transitions del módulo 17 deben respetarla también.
Zoom. Nunca pongas user-scalable=no en la etiqueta viewport. Y usa unidades relativas como rem, que respetan el tamaño de letra del navegador.
9. Probar la accesibilidad
Linter: oxlint con el plugin jsx-a11y
El linter de la plantilla (lo viste en el módulo 01) trae las reglas de accesibilidad, pero hay que activarlas:
npx oxlint --jsx-a11y-plugin
O de forma permanente, en .oxlintrc.json:
{
"plugins": ["react", "typescript", "jsx-a11y"],
"rules": {
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/label-has-associated-control": "error"
}
}
Sobre PaginaMala.tsx, informa de esto (salida abreviada):
PaginaMala.tsx:12:9: jsx-a11y(alt-text): Missing `alt` attribute.
PaginaMala.tsx:19:9: jsx-a11y(click-events-have-key-events)
PaginaMala.tsx:19:10: jsx-a11y(no-static-element-interactions)
PaginaMala.tsx:25:9: jsx-a11y(control-has-associated-label)
PaginaMala.tsx:35:7: jsx-a11y(anchor-has-content)
Es la capa más barata, porque te avisa en el editor, y detecta el div con onClick, que axe no ve. Con todas las reglas activadas (-D jsx-a11y) también genera falsos positivos. Por ejemplo, propone <output> por role="status", y protesta por role="listbox" en un ul, cuando es lo correcto para un combobox a mano. Elige las reglas o desactiva esas en tu configuración.
axe en el navegador
axe-core es el motor de reglas más usado, el que hay dentro de muchas herramientas de auditoría. Prefiere no dar falsos positivos, así que lo que señala suele ser cierto. Con Playwright, @axe-core/playwright lo ejecuta en la página real, con su CSS:
// e2e/a11y.spec.ts
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
const PAGINAS = ['buena', 'desplegable', 'pestanas', 'anunciador', 'lista', 'grupo', 'formulario', 'combobox', 'caso']
for (const pagina of PAGINAS) {
test(`${pagina} no tiene infracciones de axe`, async ({ page }) => {
await page.goto(`/?ej=${pagina}`)
await page.getByRole('heading', { level: 1 }).or(page.getByRole('heading', { level: 2 })).first().waitFor()
const resultado = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'])
.analyze()
expect(resultado.violations).toEqual([])
})
}
npx playwright test
Sobre la página mala, el informe real tiene 8 infracciones:
| Regla | Gravedad | Elementos | Qué dice |
|---|---|---|---|
button-name | Crítica | 1 | El botón sin nombre |
image-alt | Crítica | 1 | La imagen sin alt |
color-contrast | Grave | 1 | El párrafo tenue |
link-name | Grave | 1 | El enlace vacío |
heading-order | Moderada | 1 | Salto de h3 a h5 |
landmark-one-main | Moderada | 1 | No hay main |
page-has-heading-one | Moderada | 1 | No hay h1 |
region | Moderada | 5 | Contenido fuera de las regiones |
Todas las páginas de este módulo terminan con cero infracciones, también el caso final en sus tres idiomas. Fue útil: en la primera pasada axe me señaló desplegable, pestanas y caso porque había puesto un h2 sin h1 ni main.
withTags elige qué reglas se aplican. Las etiquetas wcag2a, wcag2aa, wcag21aa y wcag22aa corresponden a los criterios, y best-practice añade recomendaciones como heading-order o region, que no son incumplimientos estrictos de WCAG.
💡 Consejo: Para ver el resultado mientras desarrollas, las extensiones del navegador como axe DevTools y la pestaña Lighthouse de Chrome hacen revisiones similares con un clic.
axe en pruebas de componente
También puedes ejecutar axe-core en jsdom, junto a tus pruebas del módulo 22. Es más rápido, pero jsdom no calcula el CSS real: el contraste no se puede medir y hay que desactivar esa regla.
Un comparador propio ocupa pocas líneas y no depende de paquetes de terceros:
// src/pruebas/preparar.ts
import '@testing-library/jest-dom/vitest'
import axe from 'axe-core'
import { expect } from 'vitest'
// Un comparador propio: toNoTieneInfracciones()
expect.extend({
async toNoTieneInfracciones(contenedor: Element) {
const { violations } = await axe.run(contenedor, {
rules: { 'color-contrast': { enabled: false } }, // jsdom no calcula estilos reales
})
return {
pass: violations.length === 0,
message: () =>
violations.map((v) => `${v.id}: ${v.help} (${v.nodes.length})`).join('\n') ||
'Se esperaban infracciones y no hay ninguna',
}
},
})
declare module 'vitest' {
interface Matchers {
toNoTieneInfracciones(): Promise<void>
}
}
// vitest.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.{ts,tsx}'],
setupFiles: ['./src/pruebas/preparar.ts'],
},
})
Lo usarás en el ejercicio 6.
💡 Consejo: Existen paquetes como
vitest-axeyjest-axeque traen este comparador hecho. Este módulo no los usa porqueaxe-coredirecto solo son diez líneas y no añade otra dependencia. El paquetevitest-axeestaba en la versión 0.1.0 al escribir esto, y no se ha probado con Vitest 5.
Lo que ninguna herramienta ve
Según el estudio de Deque sobre más de 13 000 páginas auditadas, las pruebas automáticas basadas en axe-core encuentran el 57,38 % de los problemas. Dicho de otra forma, casi la mitad requiere a una persona.
La página mala es un ejemplo perfecto. axe encuentra 8 infracciones, y aun así se le escapa:
- El
div“Buscar”, que no se puede usar con teclado. Es lo más grave de la página, y lo detecta el linter o una persona que pruebe a usar Tab. - El campo cuyo único nombre es un
placeholder. - Un
altque mienta.alt="imagen"pasa cualquier revisión. <html lang="en">en una página en español.- Un orden de foco sin sentido, un mensaje incomprensible, o un foco perdido tras borrar.
Un cero en axe significa que no se ha encontrado lo que axe sabe buscar, y no que la página sea accesible.
Figura 11. Cada capa detecta cosas que las demás no ven. La última no se puede automatizar.
10. Probar como una persona
Solo con teclado
Es gratis y lo puedes hacer ya: suelta el ratón y usa tu aplicación durante cinco minutos.
- ¿Puedes llegar a todo con Tab?
- ¿Ves siempre dónde está el foco?
- ¿El orden tiene sentido?
- ¿Puedes abrir y cerrar todo? ¿Esc funciona?
- Después de cada acción, ¿sabes dónde está el foco?
- ¿Te queda atrapado en algún sitio?
Con un lector de pantalla
| Lector | Sistema | Precio | Cómo empezar |
|---|---|---|---|
| NVDA | Windows | Gratuito | Instálalo desde nvaccess.org; se usa mucho con Firefox y Chrome |
| Narrador | Windows | Incluido | Windows + Ctrl + Intro |
| VoiceOver | macOS, iOS | Incluido | Cmd + F5 en macOS |
| JAWS | Windows | De pago | Muy extendido en empresas |
| TalkBack | Android | Incluido | Ajustes de accesibilidad |
Órdenes básicas de NVDA para empezar: H siguiente título, D siguiente región, Tab siguiente control, Insert + F7 listas de títulos y enlaces, Ctrl silenciar, y Insert + Espacio cambiar entre modo exploración y modo foco.
Prueba estas tareas con los ojos cerrados o con la pantalla apagada:
- Al entrar, ¿sabes en qué página estás?
- ¿Puedes llegar directamente al contenido?
- Rellena el formulario con un error. ¿Te enteras de lo que ha ido mal y dónde?
- Busca algo. ¿Te enteras de cuántos resultados hay?
- Borra un elemento. ¿Sabes dónde te has quedado?
⚠️ Importante: Los lectores no se comportan igual entre sí, y el mismo ARIA puede anunciarse de forma distinta en cada combinación de lector y navegador. Si tu proyecto tiene requisitos legales, prueba al menos con dos combinaciones, y con personas que usen estas herramientas a diario si es posible.
Lista de comprobación rápida
| Revisar | Cómo |
|---|---|
lang en <html> | Lee index.html |
Un h1 y títulos ordenados | axe, o la lista de títulos de NVDA |
| Regiones | Árbol de accesibilidad |
Imágenes con alt útil o vacío | Una persona |
| Todo se maneja con teclado | Sin ratón |
| Foco visible | Sin ratón |
| Foco tras cada acción | Sin ratón, abrir, cerrar, borrar |
Campos con <label> | axe |
| Errores anunciados | Lector de pantalla |
| Cambios dinámicos anunciados | Lector de pantalla |
| Contraste | axe y DevTools |
| Zoom al 200 % | Ctrl y + |
| Reducir movimiento | DevTools, pestaña Rendering |
11. Intl: formatear sin bibliotecas
A partir de aquí cambiamos de tema, aunque no tanto: traducir también es hacer la aplicación usable para más personas. Y el lector de pantalla depende del idioma declarado para pronunciar bien.
Dos términos que se confunden a menudo:
- Internacionalización (i18n, porque hay 18 letras entre la i y la n): preparar el código para que admita varios idiomas y regiones.
- Localización (l10n): adaptar la aplicación a uno en concreto: traducir textos, formatos, moneda.
Antes de traducir nada, hay una parte que el navegador ya resuelve. El objeto global Intl formatea números, fechas, listas y plurales para cualquier idioma, sin añadir ni un byte a tu aplicación. Se basa en los datos de Unicode, los mismos que usa el sistema operativo.
Una etiqueta de idioma (locale) sigue la norma BCP 47: es (español), es-ES (de España), es-MX (de México), en-US, ar-EG.
Todas las salidas de esta sección son reales, en Node 24. Los navegadores actuales dan lo mismo.
Números y moneda
const dinero = 1234.5
new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(dinero)
| Idioma | Resultado |
|---|---|
es-ES | 1234,50 € |
en-US | €1,234.50 |
de-DE | 1.234,50 € |
es-MX | EUR 1,234.50 |
ar-EG | ١٬٢٣٤٫٥٠ € |
Observa dos cosas. El alemán escribe 1.234,50 y el español de España 1234,50, sin punto. No es un fallo: la norma española no separa los millares con solo cuatro cifras.
new Intl.NumberFormat('es-ES').format(1234) // '1234'
new Intl.NumberFormat('es-ES').format(12345) // '12.345'
new Intl.NumberFormat('en-US').format(1234) // '1,234'
new Intl.NumberFormat('es-ES', { useGrouping: 'always' }).format(1234) // '1.234'
Y la moneda no depende del idioma. currency es un dato: un importe en euros sigue en euros aunque se muestre en inglés. No conviertas el símbolo según el idioma, a menos que conviertas también la cantidad.
Otras opciones útiles:
new Intl.NumberFormat('es-ES', { notation: 'compact' }).format(1500000) // '1,5 M'
new Intl.NumberFormat('es-ES', { style: 'percent', maximumFractionDigits: 1 }).format(0.1275) // '12,8 %'
new Intl.NumberFormat('es-ES', { style: 'unit', unit: 'kilometer-per-hour' }).format(90) // '90 km/h'
⚠️ Importante: Entre el importe y el símbolo € hay un espacio duro (U+00A0), no un espacio normal. Los códigos reales de
5,00 €son35 2c 30 30 a0 20ac. Por esoexpect(texto).toBe('5,00 €')falla aunque se vea igual. En las pruebas, compara con el resultado deIntl, o normaliza contexto.replace(/\s/g, ' ').
Fechas
const fecha = new Date('2010-01-15T00:00:00Z')
new Intl.DateTimeFormat('es-ES', { dateStyle: 'long', timeZone: 'UTC' }).format(fecha)
| Opciones | es-ES | en-US |
|---|---|---|
dateStyle: 'short' | 15/1/10 | 1/15/10 |
dateStyle: 'medium' | 15 ene 2010 | |
dateStyle: 'long' | 15 de enero de 2010 | January 15, 2010 |
dateStyle: 'short', timeStyle: 'short' | 15/1/10, 0:00 |
El 1/15/10 estadounidense frente al 15/1/10 español es la razón por la que nunca debes construir fechas a mano con getDate() + '/' + getMonth().
Zonas horarias
La hora que muestras depende de la zona del navegador, salvo que digas otra cosa. Mira esta medianoche y media del 1 de enero, en UTC:
Madrid: 1/1/26, 1:30
Nueva York: 31/12/25, 19:30
Hay una trampa que aparece en cuanto la API devuelve solo una fecha, como fechaAlta: "2010-01-15". new Date('2010-01-15') se interpreta como medianoche UTC. Formateada en Nueva York:
new Date('2010-01-15').toLocaleDateString('es-ES', { timeZone: 'America/New_York' })
// '14/1/2010'
¡Un día menos! Si el dato es una fecha sin hora, dale formato con timeZone: 'UTC', como hará el caso final.
Tiempos relativos
const relativo = new Intl.RelativeTimeFormat('es', { numeric: 'auto' })
relativo.format(-1, 'day') // 'ayer'
relativo.format(1, 'day') // 'mañana'
relativo.format(-3, 'month') // 'hace 3 meses'
relativo.format(2, 'week') // 'dentro de 2 semanas'
new Intl.RelativeTimeFormat('es').format(-1, 'day') // 'hace 1 día'
Listas
Unir con comas parece fácil hasta que llega la gramática:
const y = new Intl.ListFormat('es', { type: 'conjunction' })
y.format(['Ventas', 'Informática', 'Dirección']) // 'Ventas, Informática y Dirección'
y.format(['Ana', 'Inés']) // 'Ana e Inés'
new Intl.ListFormat('es', { type: 'disjunction' }).format(['Ventas', 'Informática', 'Dirección'])
// 'Ventas, Informática o Dirección'
new Intl.ListFormat('en', { type: 'conjunction' }).format(['Ventas', 'Informática', 'Dirección'])
// 'Ventas, Informática, and Dirección'
Fíjate en el “Ana e Inés”: sabe que delante de un sonido i la conjunción cambia. Y en la coma de Oxford del inglés.
Plurales
Intl.PluralRules te dice en qué categoría cae un número en cada idioma. Ahí está el motivo por el que un simple n === 1 ? a : b no sirve:
| Idioma | Categorías | Ejemplos reales |
|---|---|---|
en | one, other | 1: one; 0, 2, 100: other |
es | one, many, other | 1: one; 2, 100: other; 1 000 000: many |
pl | one, few, many, other | 1: one; 2, 3: few; 0, 11, 100: many |
ar | zero, one, two, few, many, other | 0: zero; 1: one; 2: two; 3: few; 11: many; 100: other |
El español tiene many para los múltiplos exactos de un millón, porque se dice “1 000 000 de empleados” y “1 500 000 empleados”, sin de. Un detalle que volverá a aparecer enseguida, y que da un disgusto si no lo conoces.
Ordenar
sort() compara códigos Unicode, y eso coloca las mayúsculas antes y las tildes y la ñ al final:
const nombres = ['Ñu', 'Zoe', 'ana', 'Álvaro', 'Nacho', 'Óscar']
nombres.sort()
// Nacho Zoe ana Álvaro Ñu Óscar
nombres.sort(new Intl.Collator('es').compare)
// Álvaro ana Nacho Ñu Óscar Zoe
| Datos | es | en |
|---|---|---|
Nube, Ñu, Nuria | Nube Nuria Ñu | Ñu Nube Nuria |
La ñ es una letra propia en español, y por eso va después de la n. En inglés es una n con adorno.
Dos opciones prácticas:
// Números dentro del texto
['pedido10', 'pedido9', 'pedido100'].sort(new Intl.Collator('es', { numeric: true }).compare)
// pedido9 pedido10 pedido100
// Comparar sin tener en cuenta mayúsculas ni tildes
new Intl.Collator('es', { sensitivity: 'base' }).compare('Garcia', 'garcía') // 0, son iguales
Nombres y caracteres
new Intl.DisplayNames('es', { type: 'language' }).of('ar') // 'árabe'
new Intl.DisplayNames('es', { type: 'region' }).of('MX') // 'México'
new Intl.DisplayNames('es', { type: 'currency' }).of('JPY') // 'yen japonés'
new Intl.DisplayNames('ar', { type: 'language' }).of('ar') // 'العربية'
const texto = 'María👩💻'
texto.length // 10
[...new Intl.Segmenter('es', { granularity: 'grapheme' }).segment(texto)].length // 6
length cuenta unidades UTF-16, no lo que una persona ve. Segmenter cuenta caracteres visibles: úsalo para un contador de “quedan X caracteres”.
Intl en React: reutiliza los formateadores
Crear un formateador es caro, pero usarlo es barato. En una prueba con 2000 llamadas a un formato de moneda, crear uno cada vez tardó 705 ms, y reutilizar el mismo, 15 ms. En una tabla, eso se nota.
// Dentro de un componente: uno por cada idioma, con useMemo
const moneda = useMemo(
() => new Intl.NumberFormat(idioma, { style: 'currency', currency: 'EUR' }),
[idioma],
)
// Fuera del componente, si el idioma es fijo
const MONEDA = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })
⚠️ Importante: Con Server Components o SSR (módulo 23), el servidor y el navegador pueden tener idioma y zona horaria distintos. El HTML no coincidirá y la hidratación fallará. Pasa siempre el idioma y la zona de forma explícita, sin usar
undefinedni los valores por defecto.
12. i18n con un Context propio
Antes de instalar nada, conviene saber qué hace una biblioteca de traducción por dentro. Con pocos textos y dos idiomas, además, esto puede bastarte.
Mi propio t() 🆕 19.0
🆕 Desde React 19.0.
<Contexto value>yuse(Contexto)no existen en 18.3. Si usas una versión anterior: escribe<Contexto.Provider value>yuseContext(Contexto).
// src/ej/i18nPropio.tsx
import { createContext, use, useCallback, useMemo, useState, type ReactNode } from 'react'
type Idioma = 'es' | 'en'
// Un texto normal, o sus formas de plural por categoría de Intl.PluralRules
type Texto = string | Partial<Record<Intl.LDMLPluralRule, string>>
const es = {
titulo: 'Plantilla',
saludo: 'Hola, {nombre}',
total: { one: '{cuenta} empleado', other: '{cuenta} empleados' },
} satisfies Record<string, Texto>
type Clave = keyof typeof es
const en: Record<Clave, Texto> = {
titulo: 'Staff',
saludo: 'Hello, {nombre}',
total: { one: '{cuenta} employee', other: '{cuenta} employees' },
}
const DICCIONARIOS: Record<Idioma, Record<Clave, Texto>> = { es, en }
type Variables = Record<string, string | number>
interface ContextoI18n {
idioma: Idioma
cambiarIdioma: (idioma: Idioma) => void
t: (clave: Clave, variables?: Variables) => string
}
const Contexto = createContext<ContextoI18n | null>(null)
export function ProveedorI18n({ children }: { children: ReactNode }) {
const [idioma, setIdioma] = useState<Idioma>('es')
const t = useCallback<ContextoI18n['t']>(
(clave, variables = {}) => {
const texto = DICCIONARIOS[idioma][clave]
const forma =
typeof texto === 'string'
? texto
: texto[new Intl.PluralRules(idioma).select(Number(variables.cuenta))] ?? texto.other ?? clave
return forma.replace(/\{(\w+)\}/g, (_, nombre: string) => {
const valor = variables[nombre]
return typeof valor === 'number'
? new Intl.NumberFormat(idioma).format(valor)
: (valor ?? `{${nombre}}`)
})
},
[idioma],
)
const valor = useMemo(() => ({ idioma, cambiarIdioma: setIdioma, t }), [idioma, t])
return <Contexto value={valor}>{children}</Contexto>
}
export function useI18n() {
const contexto = use(Contexto)
if (!contexto) throw new Error('useI18n exige <ProveedorI18n>')
return contexto
}
function Demo() {
const { idioma, cambiarIdioma, t } = useI18n()
return (
<main className="tarjeta" lang={idioma}>
<h1>{t('titulo')}</h1>
<p>{t('saludo', { nombre: 'Ana' })}</p>
<p>{t('total', { cuenta: 1 })} / {t('total', { cuenta: 1500 })}</p>
<button type="button" onClick={() => cambiarIdioma(idioma === 'es' ? 'en' : 'es')}>
{idioma === 'es' ? 'English' : 'Español'}
</button>
</main>
)
}
export default function I18nPropio() {
return (
<ProveedorI18n>
<Demo />
</ProveedorI18n>
)
}
Resultado real: en español se ve 1 empleado / 1500 empleados, y al cambiar, 1 employee / 1,500 employees. La función t hace tres cosas:
- Busca la clave en el diccionario del idioma activo.
- Si es un plural, elige la forma con
Intl.PluralRules, y recurre aothersi falta. - Sustituye
{nombre}, y formatea los números.
satisfies conserva las claves literales de es, de modo que el tipo Clave no permite pedir una inexistente, y Record<Clave, Texto> obliga a que en tenga las mismas.
Lo que le falta
| Necesidad | Context propio | react-i18next |
|---|---|---|
| Interpolación y plurales | Lo que tú escribas | Incluido |
| Claves con tipos | Con TypeScript | Incluido |
| Texto con enlaces o negrita | Hay que programarlo | <Trans> |
| Cargar solo el idioma activo | Hay que programarlo | Con un backend |
| Dividir en varios ficheros | Hay que programarlo | Espacios de nombres |
| Detectar el idioma del navegador | Hay que programarlo | Con un plugin |
| Idioma de respaldo | Hay que programarlo | fallbackLng |
| Tamaño añadido | Casi nada | Unos 25 kB comprimidos con gzip, medido en el vite build de este módulo |
Con dos idiomas y 20 cadenas, el Context es razonable. En cuanto la traducción la hacen otras personas o el número de cadenas crece, usa una biblioteca. Este curso elige react-i18next por ser la opción más extendida y porque sus archivos JSON son fáciles de entregar a quien traduce.
13. react-i18next
i18next es el motor y funciona sin React. react-i18next lo conecta con los componentes. Se han comprobado i18next 26.7.1, react-i18next 17.1.11, i18next-browser-languagedetector 8.3.0 e i18next-resources-to-backend 1.2.4, con React 19.3.1 y TypeScript 7.0.2, sin ningún conflicto al instalar.
📘 Concepto: Un espacio de nombres (namespace) es un fichero de traducciones. Separarlos por funcionalidad (
comun,empleados,pedidos) permite cargar solo lo necesario y repartir el trabajo. Una clave es el identificador de un texto (columnas.salario), que se resuelve al texto de cada idioma.
Configuración
// src/i18n/config.ts
import i18n from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next'
export const IDIOMAS = ['es', 'en', 'ar'] as const
await i18n
.use(LanguageDetector) // decide el idioma inicial
.use(
// Vite crea un trozo aparte por cada JSON: solo se descarga el que se usa
resourcesToBackend(
(idioma: string, espacio: string) => import(`./locales/${idioma}/${espacio}.json`),
),
)
.use(initReactI18next) // conecta i18next con los hooks de React
.init({
supportedLngs: IDIOMAS,
fallbackLng: 'es', // si falta una clave, se usa este idioma
ns: ['comun', 'empleados'],
defaultNS: 'comun',
interpolation: { escapeValue: false }, // React ya escapa
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'idioma',
caches: ['localStorage'],
},
})
export default i18n
La importas en main.tsx, antes que el resto de la aplicación:
// src/main.tsx
import './i18n/config' // primero: configura i18next
import { StrictMode, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import CasoFinal from './ej/CasoFinal'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Suspense fallback={<p>Cargando…</p>}>
<CasoFinal />
</Suspense>
</StrictMode>,
)
awaita nivel de módulo hace que la aplicación no arranque hasta tener las traducciones iniciales.<Suspense>cubre el resto: un espacio de nombres que no esté listado ennsse cargará después, yuseTranslationsuspende el componente mientras tanto (el módulo 15 lo explica).supportedLngslimita los idiomas válidos: un navegador enfr-FRcae en el respaldo, y uno enen-GBusaen. Ambas cosas están comprobadas.
Los ficheros de traducción
src/i18n/
├── config.ts
├── i18next.d.ts
└── locales/
├── es/
│ ├── comun.json
│ └── empleados.json
├── en/
│ ├── comun.json
│ └── empleados.json
└── ar/
├── comun.json
└── empleados.json
// src/i18n/locales/es/comun.json
{
"app": "Gestión de EMPRESA",
"idioma": "Idioma",
"cargando": "Cargando…",
"saltar": "Saltar al contenido",
"lineas_zero": "Tu carrito está vacío",
"lineas_one": "Tienes {{count}} producto",
"lineas_many": "Tienes {{count, number}} de productos",
"lineas_other": "Tienes {{count, number}} productos"
}
// src/i18n/locales/es/empleados.json
{
"titulo": "Empleados",
"buscar": "Buscar empleados",
"total_one": "{{count, number}} empleado encontrado",
"total_many": "{{count, number}} de empleados encontrados",
"total_other": "{{count, number}} empleados encontrados",
"aviso": "Los salarios son <strong>brutos mensuales</strong>. Consulta la <enlace>política salarial</enlace>.",
"tabla": "Empleados ordenados por {{columna}}",
"columnas": {
"nombre": "Nombre",
"fechaAlta": "Fecha de alta",
"salario": "Salario"
},
"ordenarPor": "Ordenar por {{columna}}"
}
// src/i18n/locales/en/empleados.json
{
"titulo": "Employees",
"buscar": "Search employees",
"total_one": "{{count, number}} employee found",
"total_other": "{{count, number}} employees found",
"aviso": "Salaries are <strong>gross monthly</strong>. See the <enlace>pay policy</enlace>.",
"tabla": "Employees sorted by {{columna}}",
"columnas": {
"nombre": "Name",
"fechaAlta": "Hire date",
"salario": "Salary"
},
"ordenarPor": "Sort by {{columna}}"
}
// src/i18n/locales/ar/empleados.json
{
"titulo": "الموظفون",
"buscar": "ابحث عن الموظفين",
"total_zero": "لم يتم العثور على موظفين",
"total_one": "تم العثور على موظف واحد",
"total_two": "تم العثور على موظفَين",
"total_few": "تم العثور على {{count, number}} موظفين",
"total_many": "تم العثور على {{count, number}} موظفًا",
"total_other": "تم العثور على {{count, number}} موظف",
"aviso": "الرواتب <strong>شهرية إجمالية</strong>. راجع <enlace>سياسة الرواتب</enlace>.",
"tabla": "الموظفون مرتبون حسب {{columna}}",
"columnas": {
"nombre": "الاسم",
"fechaAlta": "تاريخ التعيين",
"salario": "الراتب"
},
"ordenarPor": "الترتيب حسب {{columna}}"
}
Los ficheros en/comun.json y ar/comun.json siguen la misma forma:
// src/i18n/locales/en/comun.json
{
"app": "COMPANY management",
"idioma": "Language",
"cargando": "Loading…",
"saltar": "Skip to content",
"lineas_zero": "Your cart is empty",
"lineas_one": "You have {{count}} item",
"lineas_other": "You have {{count, number}} items"
}
// src/i18n/locales/ar/comun.json
{
"app": "إدارة الشركة",
"idioma": "اللغة",
"cargando": "جارٍ التحميل…",
"saltar": "انتقل إلى المحتوى"
}
En un JSON de verdad no puede haber comentarios: la primera línea de estos bloques solo indica la ruta.
Usar las traducciones
const { t, i18n } = useTranslation('empleados') // espacio de nombres
t('titulo') // 'Empleados'
t('columnas.salario') // 'Salario': el punto entra en el objeto
t('ordenarPor', { columna: 'Salario' }) // interpolación
t('total', { count: 24 }) // plurales: la variable se llama siempre count
t('comun:app') // otro espacio de nombres
i18n.resolvedLanguage // 'es'
await i18n.changeLanguage('en') // cambia y vuelve a renderizar
Figura 12. El camino de una llamada a t() y lo que ocurre al llamar a changeLanguage.
Plurales: cuidado con _many
i18next usa Intl.PluralRules. Le pasas count, y él añade a la clave el sufijo de la categoría: total_one, total_other. Lo he probado con un diccionario que solo tiene las dos formas que cualquiera escribiría en español:
{ "total_one": "{{count, number}} empleado", "total_other": "{{count, number}} empleados" }
count | t('total', { count }) |
|---|---|
| 0 | 0 empleados |
| 1 | 1 empleado |
| 2 | 2 empleados |
| 1500000 | 1.500.000 empleados |
| 1000000 | total |
Con exactamente un millón, el resultado es el nombre de la clave. Intl.PluralRules('es') dice many, no existe total_many, y no recurre a _other. Es un error que se oculta muy bien: nadie prueba con un millón, y un día aparece un “total” en la pantalla de producción. Por eso el fichero es/empleados.json lleva su total_many, y ahora el resultado es 1.000.000 de empleados.
Otros idiomas tienen este mismo problema con sus propias categorías. Para el árabe hacen falta las seis formas, como has visto en su fichero. Comprueba siempre las categorías de cada idioma con new Intl.PluralRules(idioma).resolvedOptions().pluralCategories.
Además, si existe _zero, i18next lo usa con count: 0 aunque el idioma no tenga esa categoría. Es útil para escribir “Tu carrito está vacío” en lugar de “0 productos”.
⚠️ Importante: Si falta una clave,
tno da error: devuelve la propia clave. Si no usas tipos, una errata en el nombre se ve solo en pantalla. Por eso conviene la siguiente sección.
Formatos dentro del texto
i18next delega en Intl, y el formato se escribe dentro de la propia traducción. Son salidas reales:
| Texto | Valor | es | en |
|---|---|---|---|
{{count, number}} | 1500 | 1500 | 1,500 |
{{v, currency(currency: EUR)}} | 1234.5 | 1234,50 € | €1,234.50 |
{{f, datetime(dateStyle: long; timeZone: UTC)}} | 2010-01-15 | 15 de enero de 2010 | January 15, 2010 |
{{l, list}} | ['Ana', 'Luis', 'Marta'] | Ana, Luis y Marta | |
{{r, relativetime(day)}} | -3 | hace 3 días |
Las opciones van separadas con ;, entre paréntesis.
Texto con marcado: <Trans>
¿Y si la frase lleva un enlace en medio? Separarla en tres trozos es un error, porque cada idioma ordena la frase de manera distinta. <Trans> traduce la frase completa y sustituye etiquetas por componentes:
<Trans
i18nKey="aviso"
ns="empleados"
components={{ strong: <strong />, enlace: <a href="#politica" /> }}
/>
{ "aviso": "Los salarios son <strong>brutos mensuales</strong>. Consulta la <enlace>política salarial</enlace>." }
Nunca concatenes frases con partes traducidas: t('hola') + nombre + t('bienvenido') funciona en tu idioma y queda mal en casi todos los demás.
Interpolación y seguridad
escapeValue: false significa que i18next no escapa los valores, porque React ya escapa todo lo que pinta como texto (lo viste en el módulo 02). Comprobado, t('hola', { nombre: '<b>Ana</b>' }) devuelve el texto tal cual, y React lo muestra como texto. Si lo metieras en dangerouslySetInnerHTML, tendrías un XSS.
Claves con tipos
Crea un fichero de declaración, y TypeScript comprobará cada clave contra el JSON español:
// src/i18n/i18next.d.ts
import 'i18next'
import type comun from './locales/es/comun.json'
import type empleados from './locales/es/empleados.json'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'comun'
resources: {
comun: typeof comun
empleados: typeof empleados
}
}
}
El efecto es inmediato. Al escribir una clave que no existe, tsc responde (salida real, abreviada):
error TS2345: Argument of type '["lineas", { count: number; }]' is not assignable to parameter of type ...
Type '"lineas"' is not assignable to type '"app" | "cargando" | "idioma" | "saltar" | ...
El editor autocompleta además los nombres, y entiende los plurales: escribes t('total', ...) aunque en el JSON la clave sea total_one.
Carga perezosa
resourcesToBackend recibe una función que hace import(). Vite ve el patrón ./locales/${idioma}/${espacio}.json y genera un trozo aparte por cada fichero. En el vite build, cada comun y empleados salió por separado, y la lista de peticiones reales fue:
Al abrir en español: /es/comun.json /es/empleados.json
Al cambiar a inglés: /en/comun.json /en/empleados.json
Al cambiar a árabe: /ar/comun.json /ar/empleados.json
Es decir: al entrar en español, solo es. Las de en y ar llegan cuando se eligen.
14. Caso completo: tabla de empleados en tres idiomas 🆕 19.0
🆕 Desde React 19.0. Este ejemplo usa
<title>dentro de un componente. Si usas una versión anterior: sustitúyelo pordocument.title = t('app')en el efecto que actualizalangydir.
Reúne casi todo lo del módulo: datos de la API, tabla con orden accesible, región viva, Intl, plurales, Trans, cambio de idioma y escritura de derecha a izquierda.
// src/ej/CasoFinal.tsx
import { useEffect, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { buscarEmpleados, type Empleado } from '../api'
import { IDIOMAS } from '../i18n/config'
type Columna = 'nombre' | 'fechaAlta' | 'salario'
type Direccion = 'ascending' | 'descending'
type Orden = { columna: Columna; direccion: Direccion }
const COLUMNAS: Columna[] = ['nombre', 'fechaAlta', 'salario']
interface CabeceraProps {
columna: Columna
orden: Orden
alOrdenar: (columna: Columna) => void
}
function Cabecera({ columna, orden, alOrdenar }: CabeceraProps) {
const { t } = useTranslation('empleados')
const titulo = t(`columnas.${columna}`)
const activa = orden.columna === columna
return (
<th scope="col" aria-sort={activa ? orden.direccion : undefined}>
<button type="button" aria-label={t('ordenarPor', { columna: titulo })} onClick={() => alOrdenar(columna)}>
{titulo}
<span aria-hidden="true">
{activa ? (orden.direccion === 'ascending' ? ' ▲' : ' ▼') : ''}
</span>
</button>
</th>
)
}
function Selectores() {
const { t, i18n } = useTranslation()
const nombres = useMemo(
() => Object.fromEntries(
// Cada idioma se escribe en su propio idioma, para que siempre se reconozca
IDIOMAS.map((l) => [l, new Intl.DisplayNames(l, { type: 'language' }).of(l)]),
),
[],
)
return (
<div className="fila">
<label htmlFor="idioma">{t('idioma')}</label>
<select
id="idioma"
value={i18n.resolvedLanguage}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
{IDIOMAS.map((l) => (
<option key={l} value={l} lang={l}>{nombres[l]}</option>
))}
</select>
</div>
)
}
function TablaEmpleados() {
const { t, i18n } = useTranslation('empleados')
const [texto, setTexto] = useState('')
const [empleados, setEmpleados] = useState<Empleado[]>([])
const [orden, setOrden] = useState<Orden>({
columna: 'nombre',
direccion: 'ascending',
})
useEffect(() => {
const control = new AbortController()
buscarEmpleados(texto, control.signal)
.then((pagina) => setEmpleados(pagina.contenido))
.catch(() => {})
return () => control.abort()
}, [texto])
const idioma = i18n.resolvedLanguage ?? 'es'
const filas = useMemo(() => {
const comparador = new Intl.Collator(idioma)
const signo = orden.direccion === 'ascending' ? 1 : -1
return [...empleados].sort((a, b) => {
if (orden.columna === 'salario') return (a.salario - b.salario) * signo
if (orden.columna === 'fechaAlta') return a.fechaAlta.localeCompare(b.fechaAlta) * signo
return comparador.compare(`${a.apellidos} ${a.nombre}`, `${b.apellidos} ${b.nombre}`) * signo
})
}, [empleados, orden, idioma])
const moneda = useMemo(
() => new Intl.NumberFormat(idioma, { style: 'currency', currency: 'EUR' }),
[idioma],
)
const fecha = useMemo(
() => new Intl.DateTimeFormat(idioma, { dateStyle: 'medium', timeZone: 'UTC' }),
[idioma],
)
function ordenarPor(columna: Columna) {
setOrden((o) => ({
columna,
direccion: o.columna === columna && o.direccion === 'ascending' ? 'descending' : 'ascending',
}))
}
return (
<section aria-labelledby="titulo-empleados">
<h1 id="titulo-empleados">{t('titulo')}</h1>
<div className="campo">
<label htmlFor="busqueda">{t('buscar')}</label>
<input id="busqueda" type="search" value={texto} onChange={(e) => setTexto(e.target.value)} />
</div>
<p role="status">{t('total', { count: filas.length })}</p>
<p>
<Trans i18nKey="aviso" ns="empleados" components={{ strong: <strong />, enlace: <a href="#politica" /> }} />
</p>
<table>
<caption>{t('tabla', { columna: t(`columnas.${orden.columna}`) })}</caption>
<thead>
<tr>
{COLUMNAS.map((columna) => (
<Cabecera key={columna} columna={columna} orden={orden} alOrdenar={ordenarPor} />
))}
</tr>
</thead>
<tbody>
{filas.slice(0, 6).map((e) => (
<tr key={e.empId}>
<th scope="row">{e.apellidos}, {e.nombre}</th>
<td>{fecha.format(new Date(e.fechaAlta))}</td>
<td className="numero">{moneda.format(e.salario)}</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
export default function CasoFinal() {
const { t, i18n } = useTranslation()
// El idioma y la dirección son del documento entero
useEffect(() => {
const raiz = document.documentElement
raiz.lang = i18n.resolvedLanguage ?? 'es'
raiz.dir = i18n.dir(i18n.resolvedLanguage)
}, [i18n, i18n.resolvedLanguage])
return (
<>
<title>{t('app')}</title>
<a href="#principal" className="saltar">{t('saltar')}</a>
<header>
<p><strong>{t('app')}</strong></p>
<Selectores />
</header>
<main id="principal" tabIndex={-1} className="tarjeta">
<TablaEmpleados />
</main>
</>
)
}
Puntos que merece la pena comentar:
Orden accesible. aria-sort va en el th, y el botón de dentro es lo que se pulsa. Las flechas ▲ y ▼ llevan aria-hidden, porque el estado ya lo da aria-sort.
Cabecera está fuera de TablaEmpleados. Tenía esto dentro en la primera versión, y es un error sutil del que hablamos en el módulo 05: un componente declarado dentro de otro es un tipo nuevo en cada render, de modo que React destruye y recrea el botón. Al pulsarlo, perdías el foco. Es un error de accesibilidad que nace de un error de React, y por eso la comprobación automática ahora incluye toBeFocused() tras ordenar.
th scope="row". La primera celda de cada fila es la cabecera de esa fila: el lector puede decir “Salario, García López, Ana, 6500 €”.
<caption> y el orden. La tabla tiene nombre, que además dice cómo está ordenada.
El nombre de cada idioma, en su idioma. Intl.DisplayNames(l, ...).of(l) da español, English, العربية, y lang={l} en cada <option> indica cómo debe pronunciarse. Si la aplicación está en un idioma que no entiendes, necesitas reconocer el tuyo.
lang y dir en <html>. Es lo que permite al lector cambiar de voz. Si solo una parte del texto está en otro idioma, usa lang en ese elemento: <span lang="en">Dashboard</span> (criterio 3.1.2).

Figura 13. En español. Los salarios se ven sin punto de millares porque es la norma con cuatro cifras.

Figura 14. En inglés, ordenada por salario. La moneda sigue siendo el euro, pero con formato inglés.

Figura 15. En árabe. Bastó con dir="rtl": la tabla, las cabeceras y el texto se invierten solos.
La misma fila en cada idioma, leída del DOM:
es García López, Ana 15 ene 2010 6500,00 €
en García López, Ana Jan 15, 2010 €6,500.00
ar García López, Ana 15/01/2010 6,500.00 €
Escribir de derecha a izquierda
El árabe, el hebreo y otras lenguas se escriben de derecha a izquierda (RTL). Con dir="rtl" en <html>, el navegador invierte el flujo. i18n.dir(idioma) da el valor: rtl para ar y he, ltr para es.
Lo que sí depende de ti es el CSS. Si escribes margin-left, ese margen seguirá a la izquierda aunque el diseño se invierta. Las propiedades lógicas se refieren al inicio y al final de la línea, no a la izquierda y la derecha:
| Física | Lógica |
|---|---|
margin-left | margin-inline-start |
padding-right | padding-inline-end |
left | inset-inline-start |
text-align: left | text-align: start |
border-bottom | border-block-end |
El CSS de este módulo ya las usa, y por eso la tabla árabe se ve bien sin ningún ajuste. Tailwind 4, que viste en el módulo 07, tiene equivalentes como ms-4 y pe-2.
Los iconos de flechas de “siguiente” también deben invertirse, y otros, como un reloj o un logotipo, no.
Pruebas con traducciones
Para probar un componente traducido, usa una instancia propia en cada prueba con el idioma fijo, sin depender del navegador: lng en lugar del detector. Lo verás en el ejercicio 5. Comprueba siempre al menos una ejecución de axe en cada idioma: en este caso, 0 infracciones en es, en y ar.
15. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
| El lector dice solo “botón” | Botón de icono sin nombre | aria-label y aria-hidden en el icono |
| No se puede llegar con Tab | div con onClick | <button> |
| Tras cerrar o borrar, se va al principio | Foco perdido en body | Decide tú el destino, con tabIndex={-1} si hace falta |
| Tras navegar, el lector no dice nada | SPA sin gestión del foco | Foco al h1 y <title> por ruta |
| El aviso no se oye | La región nace con el mensaje | Monta la región vacía antes |
| El segundo aviso igual no se oye | El texto no cambia | Vacía antes de escribir |
Falla con Esc el estado del diálogo | Falta onClose | Sincroniza el estado en onClose |
| Botón Cancelar envía el formulario | type por defecto es submit | type="button" |
.focus() no hace nada | El elemento no es enfocable, o está oculto | tabIndex={-1}, y espera a que esté en el DOM |
| Se pierde el foco al escribir u ordenar | Componente declarado dentro de otro | Decláralo fuera |
aria-hidden y aviso de axe | Contiene un elemento enfocable | Usa inert o quítalo |
La pantalla muestra total | Falta una forma de plural (_many) o la clave | Mira pluralCategories, usa tipos |
| Un día menos en la fecha | new Date('AAAA-MM-DD') es UTC | timeZone: 'UTC' |
toBe('5,00 €') falla | Espacio duro U+00A0 | Normaliza o compara con Intl |
| Ñ o tildes al final de la lista | sort() por defecto | Intl.Collator |
| El diseño no se invierte bien | Propiedades físicas | Propiedades lógicas |
| El lector pronuncia mal | lang="en" de la plantilla | Corrige index.html, sincroniza con el idioma |
16. Ejercicios
Ejercicio 1: Tarjeta inaccesible
Esta tarjeta funciona con el ratón, pero tiene seis problemas. Encuéntralos y corrígelos. Debe quedar con cero infracciones de axe y debe poderse usar con teclado.
<div className="tarjeta">
<img src={FOTO} />
<h4>{nombre}</h4>
<p>{oficio}</p>
<div onClick={() => setFavorito(!favorito)}>{favorito ? '★' : '☆'}</div>
<a href="mailto:ana.garcia@empresa.es">Clic aquí</a>
</div>
Ver solución
Los problemas son: imagen sin alt; salto de nivel de título; div clicable que no es accesible con teclado ni tiene rol; un estado (favorito) que solo se comunica con un símbolo; un icono sin nombre; y un enlace cuyo texto no dice a dónde va.
import { useState } from 'react'
const FOTO =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Ccircle cx='32' cy='32' r='32' fill='%239fa8da'/%3E%3C/svg%3E"
interface Props {
nombre: string
oficio: string
}
export function TarjetaEmpleado({ nombre, oficio }: Props) {
const [favorito, setFavorito] = useState(false)
return (
<article className="tarjeta">
<img src={FOTO} alt={`Foto de ${nombre}`} width="64" height="64" />
<h2>{nombre}</h2>
<p>{oficio}</p>
<div className="fila">
<button
type="button"
aria-pressed={favorito}
onClick={() => setFavorito((f) => !f)}
>
<span aria-hidden="true">★ </span>Favorito
</button>
<a href="mailto:ana.garcia@empresa.es">
Enviar correo a {nombre}
</a>
</div>
</article>
)
}
export default function Ej1() {
return (
<main>
<h1>Plantilla</h1>
<TarjetaEmpleado nombre="Ana García" oficio="Directora general" />
</main>
)
}
aria-pressed comunica el estado sin cambiar el nombre, y la estrella queda fuera del árbol con aria-hidden. “Enviar correo a Ana García” tiene sentido por sí solo cuando el usuario salta de enlace en enlace. Resultado comprobado: Intro cambia aria-pressed a true, y axe da 0 infracciones.
Ejercicio 2: Interruptor
Crea Interruptor con las props etiqueta, activo y alCambiar. Debe ser un role="switch", usarse con Espacio e Intro, y exponer su nombre y su estado.
Ver solución
import { useId, useState } from 'react'
interface InterruptorProps {
etiqueta: string
activo: boolean
alCambiar: (activo: boolean) => void
}
export function Interruptor({ etiqueta, activo, alCambiar }: InterruptorProps) {
const id = useId()
return (
<div className="fila">
<span id={id}>{etiqueta}</span>
<button
type="button"
role="switch"
aria-checked={activo}
aria-labelledby={id}
onClick={() => alCambiar(!activo)}
>
<span aria-hidden="true">{activo ? 'Sí' : 'No'}</span>
</button>
</div>
)
}
export default function Ej2() {
const [avisos, setAvisos] = useState(true)
return (
<main className="tarjeta">
<h1>Preferencias</h1>
<Interruptor etiqueta="Recibir avisos por correo" activo={avisos} alCambiar={setAvisos} />
</main>
)
}
Un button con role="switch" ya trae foco y teclado. aria-checked da el estado, y aria-labelledby toma el nombre del texto visible. El “Sí” o “No” visual se oculta para que no pase a formar parte del nombre. Se probó con getByRole('switch', { name: 'Recibir avisos por correo' }), comprobando toBeChecked() antes y después de pulsar Espacio.
Ejercicio 3: Acordeón con teclado
Crea un acordeón con tres secciones de condiciones laborales. Cada título es un botón con aria-expanded dentro de un h2. Las flechas arriba y abajo, Inicio y Fin mueven el foco entre los títulos, con vuelta al otro extremo.
Ver solución
import { useId, useRef, useState, type KeyboardEvent } from 'react'
const SECCIONES = [
{ titulo: 'Horario', texto: 'De 8:00 a 15:00, de lunes a viernes.' },
{ titulo: 'Vacaciones', texto: '23 días laborables al año.' },
{ titulo: 'Teletrabajo', texto: 'Dos días a la semana.' },
]
export default function Acordeon() {
const [abierta, setAbierta] = useState<number | null>(0)
const botones = useRef<(HTMLButtonElement | null)[]>([])
const base = useId()
function alPulsarTecla(evento: KeyboardEvent<HTMLDivElement>) {
const indice = botones.current.indexOf(document.activeElement as HTMLButtonElement)
if (indice === -1) return
const total = SECCIONES.length
const destinos: Record<string, number> = {
ArrowDown: (indice + 1) % total,
ArrowUp: (indice - 1 + total) % total,
Home: 0,
End: total - 1,
}
const destino = destinos[evento.key]
if (destino === undefined) return
evento.preventDefault()
botones.current[destino]?.focus()
}
return (
<main className="tarjeta">
<h1>Condiciones laborales</h1>
<div onKeyDown={alPulsarTecla}>
{SECCIONES.map((seccion, i) => (
<div key={seccion.titulo}>
<h2>
<button
type="button"
id={`${base}-boton-${i}`}
ref={(nodo) => {
botones.current[i] = nodo
}}
aria-expanded={abierta === i}
aria-controls={`${base}-panel-${i}`}
onClick={() => setAbierta(abierta === i ? null : i)}
>
{seccion.titulo}
</button>
</h2>
<div
id={`${base}-panel-${i}`}
role="region"
aria-labelledby={`${base}-boton-${i}`}
hidden={abierta !== i}
>
<p>{seccion.texto}</p>
</div>
</div>
))}
</div>
</main>
)
}
A diferencia de las pestañas, aquí todos los botones están en el orden de Tab, porque son independientes. Las flechas son un atajo y no cambian qué sección se abre. El botón va dentro del h2 para que el lector pueda saltar entre secciones por títulos. Con Playwright: flecha abajo de Horario a Vacaciones, Fin a Teletrabajo, flecha abajo vuelve a Horario, e Intro cierra la sección.
Ejercicio 4: Hace cuánto
Escribe haceCuanto(desde, ahora, idioma), que devuelva “ayer”, “hace 45 minutos” o “hace 2 años” eligiendo la unidad más grande que quepa. Recibe ahora como parámetro para poder probarla. Pruébala con Vitest en es y en.
Ver solución
// src/ej/formatos.ts
const UNIDADES: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 3600],
['month', 30 * 24 * 3600],
['day', 24 * 3600],
['hour', 3600],
['minute', 60],
]
export function haceCuanto(desde: Date, ahora: Date, idioma: string): string {
const formato = new Intl.RelativeTimeFormat(idioma, { numeric: 'auto' })
const segundos = Math.round((desde.getTime() - ahora.getTime()) / 1000)
for (const [unidad, duracion] of UNIDADES) {
if (Math.abs(segundos) >= duracion) {
return formato.format(Math.trunc(segundos / duracion), unidad)
}
}
return formato.format(0, 'second')
}
// src/ej/formatos.test.ts
import { expect, test } from 'vitest'
import { haceCuanto } from './formatos'
const ahora = new Date('2026-11-02T12:00:00Z')
test.each([
['2026-11-02T11:59:50Z', 'es', 'ahora'],
['2026-11-02T11:15:00Z', 'es', 'hace 45 minutos'],
['2026-11-01T12:00:00Z', 'es', 'ayer'],
['2026-11-04T12:00:00Z', 'es', 'pasado mañana'],
['2024-11-01T12:00:00Z', 'es', 'hace 2 años'],
['2026-11-01T12:00:00Z', 'en', 'yesterday'],
])('%s en %s', (fecha, idioma, esperado) => {
expect(haceCuanto(new Date(fecha), ahora, idioma)).toBe(esperado)
})
Se usa Math.trunc y no Math.round porque 1,9 años son “hace 1 año”. numeric: 'auto' da “ayer” y “pasado mañana” en lugar de “hace 1 día”. Recibir ahora hace pura a la función, y por eso su prueba no depende de la fecha actual. Las seis comprobaciones pasan.
Ejercicio 5: Carrito con plurales
Añade lineas a comun.json en es y en: “Tu carrito está vacío” con 0, “Tienes 1 producto” con 1, y el plural con el resto, con separador de miles según el idioma. No olvides el _many del español. Crea una instancia propia de i18next, sin el detector, para poder probarla.
Ver solución
{
"lineas_zero": "Tu carrito está vacío",
"lineas_one": "Tienes {{count}} producto",
"lineas_many": "Tienes {{count, number}} de productos",
"lineas_other": "Tienes {{count, number}} productos"
}
En en, las claves son lineas_zero, lineas_one y lineas_other, con los textos del fichero que has visto antes.
import { createInstance } from 'i18next'
import { useState } from 'react'
import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next'
import en from '../i18n/locales/en/comun.json'
import es from '../i18n/locales/es/comun.json'
// Una instancia propia, separada de la global de la aplicación
const carrito = createInstance()
await carrito.use(initReactI18next).init({
lng: 'es',
fallbackLng: 'es',
defaultNS: 'comun',
interpolation: { escapeValue: false },
resources: { es: { comun: es }, en: { comun: en } },
})
function Contador() {
const { t, i18n } = useTranslation()
const [cantidad, setCantidad] = useState(0)
return (
<main className="tarjeta">
<h1>Carrito</h1>
<p role="status">{t('lineas', { count: cantidad })}</p>
<div className="fila">
<button type="button" onClick={() => setCantidad((c) => c + 1)}>+1</button>
<button type="button" onClick={() => setCantidad(1500)}>1500</button>
<button type="button" onClick={() => i18n.changeLanguage(i18n.language === 'es' ? 'en' : 'es')}>
{i18n.language === 'es' ? 'English' : 'Español'}
</button>
</div>
</main>
)
}
export default function Ej7() {
return (
<I18nextProvider i18n={carrito}>
<Contador />
</I18nextProvider>
)
}
createInstance da un i18next independiente del global, e I18nextProvider lo pone a disposición de useTranslation. Con cada botón, el status real fue Tu carrito está vacío, Tienes 1 producto, Tienes 1500 productos (sin punto, cuatro cifras) y, al cambiar de idioma, You have 1,500 items.
Ejercicio 6: Pruebas de accesibilidad
Usa el comparador toNoTieneInfracciones de la sección 9 para escribir pruebas de la página buena y de la mala, de las pestañas con teclado, del interruptor y del carrito. Usa consultas por rol. Guarda el interruptor en src/ej/Ej2Interruptor.tsx y el carrito en src/ej/Ej7Carrito.tsx, que son los nombres que importa la solución.
Ver solución
// src/ej/accesibilidad.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { expect, test } from 'vitest'
import PaginaBuena from './PaginaBuena'
import PaginaMala from './PaginaMala'
import Pestanas from './Pestanas'
import { Interruptor } from './Ej2Interruptor'
import Ej7 from './Ej7Carrito'
test('la página buena pasa axe', async () => {
const { container } = render(<PaginaBuena />)
await expect(container).toNoTieneInfracciones()
})
test('la página mala no pasa axe', async () => {
const { container } = render(<PaginaMala />)
await expect(container).not.toNoTieneInfracciones()
})
test('las pestañas se manejan con las flechas', async () => {
const usuario = userEvent.setup()
render(<Pestanas />)
await usuario.tab()
expect(screen.getByRole('tab', { name: 'Datos' })).toHaveFocus()
await usuario.keyboard('{ArrowRight}')
expect(screen.getByRole('tab', { name: 'Salario', selected: true })).toHaveFocus()
expect(screen.getByRole('tabpanel', { name: 'Salario' })).toHaveTextContent('Salario base')
})
function InterruptorConEstado() {
const [activo, setActivo] = useState(false)
return <Interruptor etiqueta="Recibir avisos" activo={activo} alCambiar={setActivo} />
}
test('el interruptor expone su estado', async () => {
const usuario = userEvent.setup()
render(<InterruptorConEstado />)
const interruptor = screen.getByRole('switch', { name: 'Recibir avisos', checked: false })
await usuario.click(interruptor)
expect(interruptor).toBeChecked()
})
test('el carrito traduce los plurales', async () => {
const usuario = userEvent.setup()
render(<Ej7 />)
expect(screen.getByRole('status')).toHaveTextContent('Tu carrito está vacío')
await usuario.click(screen.getByRole('button', { name: '+1' }))
expect(screen.getByRole('status')).toHaveTextContent('Tienes 1 producto')
})
npx vitest run
Test Files 1 passed (1)
Tests 5 passed (5)
Fíjate en que una consulta como getByRole('tab', { name: 'Salario', selected: true }) ya es una prueba de accesibilidad: si falta el rol, el nombre o el estado, falla. El módulo 22 explica por qué estas consultas son las primeras que debes usar.
Ejercicio 7 (propuesto): Corrige la página mala tú mismo
Parte de PaginaMala y arregla los problemas uno a uno, ejecutando axe en cada paso, sin mirar PaginaBuena. Anota qué problemas no detecta axe y cómo los has encontrado.
Ejercicio 8 (propuesto): Añade un idioma
Añade francés al caso completo. Consulta new Intl.PluralRules('fr').resolvedOptions().pluralCategories antes de escribir los plurales, y comprueba con un millón de empleados que no aparece la clave en pantalla.
Ejercicio 9 (propuesto): Menú de acciones
Consulta el patrón Menu Button de la guía de prácticas de ARIA y crea un botón “Acciones” con un menú: aria-haspopup, aria-expanded, role="menu", role="menuitem", flechas, Esc y devolución del foco al botón.
Ejercicio 10 (propuesto): Paginación accesible
Añade paginación a la tabla del caso completo: un <nav aria-label> con enlaces o botones, aria-current="page" en la página actual, nombres que digan “Ir a la página 3”, y un anuncio en la región viva al cambiar. Tradúcelo a los tres idiomas.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Árbol de accesibilidad | Rol, nombre, estado | Lo que recibe el lector, derivado del DOM |
| HTML semántico | button, a, label, main, dialog | Trae rol, foco y teclado gratis |
| ARIA | role, aria-* | Cambia lo que se anuncia, no lo que se hace |
| Nombre accesible | aria-labelledby, aria-label, label, alt | Se calcula por orden; placeholder no vale |
| Estados | aria-expanded, aria-pressed, aria-selected | Son texto en el DOM: "false" se escribe |
| Inerte | inert 🔄 19.0 | Apaga una zona; booleano desde 19.0 |
| Regiones vivas | role="status", role="alert" | La región debe existir antes que el mensaje |
| Foco en rutas | tabIndex={-1}, .focus(), <title> 🆕 19.0 | Tras navegar, el foco va al h1 |
| Diálogos | <dialog>, showModal(), onClose | El navegador trae trampa de foco y Esc |
| Foco tras borrar | ref, .focus() | Si borras el foco, cae en body: decide tú |
| Fragmentos | <Fragment ref> 🆕 19.3, focus() | Enfoca el primero sin contenedor |
| Tabindex itinerante | tabIndex 0 y -1, flechas | Un grupo es una parada de Tab |
| Combobox | aria-activedescendant | El foco no se mueve, se mueve la opción activa |
| Formularios | aria-invalid, aria-describedby | Error en texto y foco al primer error |
| Contraste y tamaño | WCAG 1.4.3, 2.5.8 | 4.5:1 y 24 px |
| Movimiento | prefers-reduced-motion | Respeta la preferencia |
| Linter | oxlint --jsx-a11y-plugin | Detecta lo que axe no ve, como div clicable |
| axe | axe-core, @axe-core/playwright | Cero no significa accesible: encuentra un 57 % |
| Manual | Teclado, NVDA, VoiceOver | Lo único que dice si se puede usar |
| Números y fechas | Intl.NumberFormat, Intl.DateTimeFormat | Reutiliza el formateador; cuida la zona horaria |
| Plurales | Intl.PluralRules | El español tiene many |
| Orden | Intl.Collator | sort() no sabe ordenar texto |
| Traducción | useTranslation, t | t devuelve la clave si falta |
| Marcado en frases | <Trans> | No partas frases |
| Carga perezosa | resourcesToBackend, import() | Solo se descarga el idioma activo |
| Tipos | CustomTypeOptions | Las claves erróneas no compilan |
| RTL | i18n.dir(), dir, propiedades lógicas | margin-inline-start, no margin-left |