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> y use para los contextos, <title> en componentes y inert son 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.html y cambia lang="en" por lang="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

Cuatro pasos encadenados: JSX, DOM, árbol de accesibilidad con rol, nombre y estado, y lector de pantalla que dice el resultado

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 URL data: 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.

La página mala con su icono sin nombre, su enlace vacío y su texto tenue marcados con un borde rojo

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

La página corregida, con su título, campo etiquetado, botones con texto y el resultado de la búsqueda

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 defecto type="submit". Un botón de “Cancelar” sin type="button" envía el formulario. Por eso, todos los botones de este módulo que no envían llevan type="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 Tarjeta con un título dentro, pásale una prop para elegir la etiqueta (por ejemplo como="h3") en lugar de fijar un h2. 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, pasa undefined.
  • Los atributos booleanos de HTML (hidden, required, readOnly, disabled) desaparecen cuando valen false.

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:

  1. aria-labelledby: el texto del elemento o elementos cuyo id se cita.
  2. aria-label: el texto del atributo.
  3. La forma nativa: <label> para campos, alt para imágenes, <caption> para tablas.
  4. El contenido, en los roles que lo admiten, como botones y enlaces.
  5. Último recurso: title o placeholder.

⚠️ Importante: En la página mala, el árbol muestra textbox "Nombre" gracias al placeholder, 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. Un placeholder nunca 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-label no 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 antiguo inert="" deja de funcionar. Si usas una versión anterior: escribe inert="" para activarlo y inert={undefined} para quitarlo, y añade un // @ts-expect-error si 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> y use(Contexto) no existen en 18.3. Si usas una versión anterior: escribe <Contexto.Provider value> y useContext(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 con document.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.title cambia a Empleados | EMPRESA.
  • NavLink añade aria-current="page" al enlace activo sin que hagas nada.

Enlace Saltar al contenido visible sobre la parte superior de la página al recibir el foco

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 h1 recibe el foco, :focus-visible puede 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, usa h1: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.

Flujo del foco: del botón de borrar al diálogo; si se cancela vuelve solo, y si se borra debes llevarlo a la fila vecina

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.

Diálogo de confirmación centrado sobre la lista, con el fondo oscurecido

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 una ref en un contenedor real y busca dentro con querySelector('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}.

Tres pestañas, solo una con tabIndex 0. Tab salta del botón previo a la pestaña activa y de ahí al panel; la flecha mueve el tabIndex 0 a la siguiente

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" y role="tabpanel" se relacionan con aria-controls y aria-labelledby.
  • aria-selected indica cuál es la activa.
  • El estado guarda el índice, y la ref lleva el foco. React decide el tabIndex en 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.

Tres pestañas, con Proyectos activa y su panel debajo, con el foco visible en la pestaña

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>
  )
}

Campo Empleado con una lista de sugerencias debajo y la segunda opción resaltada

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:

  • onMouseDown con preventDefault en las opciones. Si fuera onClick, el onBlur del campo cerraría la lista antes de que llegara el clic.
  • hidden en la lista cuando no hay resultados, en lugar de quitarla: aria-controls debe citar un id que exista.
  • aria-selected indica la opción activa dentro de la lista. Existe en cada option.
  • 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 SubmitEvent porque @types/react 19.3 marca FormEvent como obsoleto. Con tipos anteriores, escribe FormEvent<HTMLFormElement>.

Al enviarlo vacío, la API devuelve el 422 con detalles, y el formulario lo presenta así:

Formulario con un resumen de cinco errores arriba y cada campo con su mensaje de error debajo

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-invalid también ayuda.
  • aria-invalid solo si hay error. Se pasa undefined cuando no lo hay, para que no aparezca.
  • aria-describedby solo 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”. required ya 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), setFocus y shouldFocusError hacen buena parte de esto, pero el aria-invalid y el aria-describedby siguen 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-axe y jest-axe que traen este comparador hecho. Este módulo no los usa porque axe-core directo solo son diez líneas y no añade otra dependencia. El paquete vitest-axe estaba 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 alt que 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.

Cuatro capas: linter sobre el código, pruebas de componente en jsdom, navegador real con axe, y personas con teclado y lector de pantalla

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.

  1. ¿Puedes llegar a todo con Tab?
  2. ¿Ves siempre dónde está el foco?
  3. ¿El orden tiene sentido?
  4. ¿Puedes abrir y cerrar todo? ¿Esc funciona?
  5. Después de cada acción, ¿sabes dónde está el foco?
  6. ¿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:

  1. Al entrar, ¿sabes en qué página estás?
  2. ¿Puedes llegar directamente al contenido?
  3. Rellena el formulario con un error. ¿Te enteras de lo que ha ido mal y dónde?
  4. Busca algo. ¿Te enteras de cuántos resultados hay?
  5. 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 € son 35 2c 30 30 a0 20ac. Por eso expect(texto).toBe('5,00 €') falla aunque se vea igual. En las pruebas, compara con el resultado de Intl, o normaliza con texto.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 undefined ni 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> y use(Contexto) no existen en 18.3. Si usas una versión anterior: escribe <Contexto.Provider value> y useContext(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:

  1. Busca la clave en el diccionario del idioma activo.
  2. Si es un plural, elige la forma con Intl.PluralRules, y recurre a other si falta.
  3. 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>,
)
  • await a 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 en ns se cargará después, y useTranslation suspende el componente mientras tanto (el módulo 15 lo explica).
  • supportedLngs limita los idiomas válidos: un navegador en fr-FR cae en el respaldo, y uno en en-GB usa en. 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

Flujo en cuatro pasos desde el idioma activo hasta el texto final, y lo que ocurre al cambiar de idioma

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, t no 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 por document.title = t('app') en el efecto que actualiza lang y dir.

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).

Tabla de empleados en español con formato de fecha y moneda españoles

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

Tabla de empleados en inglés, ordenada por salario descendente

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

Tabla de empleados en árabe, con toda la interfaz invertida de derecha a izquierda

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