Accesibilidad web: WCAG, ARIA y buenas prácticas
La accesibilidad hace que tu web funcione para todas las personas, incluidas las que usan lectores de pantalla, navegan solo con teclado o tienen baja visión. Este tutorial cubre los principios WCAG 2.2, el HTML semántico, ARIA, formularios, teclado, contraste y cómo testearlo todo. Prerequisito: HTML y CSS básicos.
Fundamentos
La accesibilidad (a11y) es diseñar y construir productos que puedan usar todas las personas, con o sin discapacidad. No es un extra: es un requisito de calidad y, en muchos países, una obligación legal.
Usuarios afectados
- Visual: ceguera, baja visión, daltonismo. Usan lectores de pantalla, zoom o alto contraste.
- Motora: dificultad para usar el ratón. Navegan con teclado, switches o voz.
- Cognitiva: dificultad para procesar información. Necesitan textos claros y navegación predecible.
- Auditiva: sordera o hipoacusia. Necesitan subtítulos y alternativas textuales al audio.
WCAG 2.2
Las Web Content Accessibility Guidelines son el estándar internacional. Se organizan en cuatro principios (POUR):
- Perceivable: la información debe poder percibirse (alternativas de texto, contraste, subtítulos).
- Operable: la interfaz debe poder usarse (teclado, tiempo suficiente, evitar convulsiones).
- Understandable: el contenido debe ser comprensible (idioma, navegación predecible, ayuda ante errores).
- Robust: debe funcionar con tecnologías de asistencia (HTML válido, ARIA correcto).
Cada criterio tiene un nivel de conformidad: A (mínimo), AA (objetivo habitual) y AAA (máximo, difícil de cumplir en todo). La mayoría de las webs apuntan a AA.
HTML semántico
El HTML semántico es la base de la accesibilidad: los lectores de pantalla y los motores de búsqueda entienden la estructura de la página a partir de las etiquetas. Antes de añadir ARIA, usa el elemento correcto.
Landmarks
Los landmarks son regiones que permiten saltar directamente a ellas. Los elementos header, nav, main y footer crean landmarks automáticos:
<header>Logo y título del sitio</header>
<nav aria-label="Principal">
<a href="/">Inicio</a>
<a href="/blog">Blog</a>
</nav>
<main>
<h1>Contenido principal</h1>
</main>
<footer>Contacto y legal</footer>Si hay varios nav, distínguelos con aria-label para que el usuario sepa a cuál saltar.
Orden de encabezados
Los encabezados deben seguir una jerarquía sin saltos: un h1 por página, luego h2, h3 en orden. Saltar de h2 a h4 confunde a los usuarios de lector de pantalla que navegan por encabezados.
h3 debe verse grande, cámbiale el estilo con CSS; no lo conviertas en h1 solo por el tamaño.button vs div clicable
Un elemento clicable debe ser un button o un a, no un div con onClick. El button nativo es enfocable, activable con Enter y Espacio, y lo anuncia el lector de pantalla:
{/* Mal: un div clicable no es enfocable ni anunciado */}
<div onClick={handleClick}>Abrir menú</div>
{/* Bien: el button nativo trae teclado y semántica */}
<button onClick={handleClick}>Abrir menú</button>Listas
Usa ul/ol para listas reales. El lector de pantalla anuncia "lista de N elementos", lo que ayuda a entender la estructura. Un menú de navegación es una lista de enlaces.
label
Todo campo de formulario necesita un label asociado. El for debe coincidir con el id del campo:
<label htmlFor="email">Correo electrónico</label>
<input id="email" type="email" name="email" />placeholder no sustituye al label: desaparece al escribir y no siempre se lee. El label es el nombre accesible del campo.ARIA
ARIA (Accessible Rich Internet Applications) añade semántica a elementos que el HTML no puede expresar. Su regla de oro: no uses ARIA si el HTML nativo ya lo hace.
button real es mejor que un div con role="button". ARIA no añade comportamiento: solo semántica. Si lo usas, debes implementar el teclado y el estado tú mismo.aria-label y aria-labelledby
aria-label da un nombre accesible a un elemento sin texto visible. aria-labelledby referencia el id de otro elemento que sirve de nombre:
{/* aria-label: nombre para un icono sin texto */}
<button aria-label="Cerrar sesión">
<svg aria-hidden="true">…</svg>
</button>
{/* aria-labelledby: el título del diálogo es su nombre */}
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirmar borrado</h2>
</div>aria-describedby
aria-describedby vincula una descripción adicional (ayuda, error) a un campo. El lector la anuncia después del nombre:
<label htmlFor="pass">Contraseña</label>
<input id="pass" type="password" aria-describedby="pass-hint" />
<p id="pass-hint">Mínimo 8 caracteres, con un número.</p>Roles
Los roles declaran qué es un elemento. Los más comunes: dialog, alert, tablist, menu. Úsalos solo cuando el HTML no tenga un equivalente.
Estados
Los estados comunican el estado de un widget. aria-expanded indica si un panel está abierto; aria-pressed si un botón de alternancia está activo:
<button aria-expanded={open} onClick={() => setOpen(!open)}>
Filtros {open ? "▲" : "▼"}
</button>
<button aria-pressed={active} onClick={() => setActive(!active)}>
Negrita
</button>Regiones vivas: aria-live
aria-live anuncia cambios dinámicos sin que el usuario tenga el foco. Es esencial para notificaciones, errores de validación y resultados de búsqueda:
{/* polite: anuncia cuando el usuario está inactivo */}
<div aria-live="polite">{statusMessage}</div>
{/* assertive: interrumpe para anunciar de inmediato */}
<div role="alert">{errorMessage}</div>aria-live="assertive" con moderación: interrumpe al usuario. Para la mayoría de los mensajes, polite es suficiente y menos invasivo.Formularios accesibles
Un formulario accesible combina labels asociados, errores vinculados y estados de validación claros.
Labels asociados
Cada campo tiene su label con htmlFor. Los campos obligatorios se marcan con required y aria-required:
<label htmlFor="nombre">Nombre *</label>
<input id="nombre" name="nombre" required aria-required="true" />Mensajes de error vinculados
El error se vincula al campo con aria-describedby y el campo inválido se marca con aria-invalid:
<label htmlFor="email">Correo</label>
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
/>
{error && <p id="email-error" role="alert">{error}</p>}Navegación por teclado
Toda la funcionalidad debe ser operable con teclado. La tecla Tab recorre los elementos enfocables en orden de documento.
tabindex
tabindex controla el orden de tabulación:
- 0: el elemento entra en el orden natural (para hacer enfocable un elemento no enfocable).
- -1: enfocable por script pero no por Tab (para mover el foco a un modal o error).
- > 0: evítalo. Rompe el orden natural del documento y es difícil de mantener.
tabindex mayor que 0. El orden de tabulación debe seguir el orden del documento; los valores positivos crean un orden artificial que confunde a los usuarios de teclado.Focus management en modales
Al abrir un modal, el foco debe moverse dentro; al cerrarlo, volver al elemento que lo abrió. Sin esto, el usuario de teclado queda "atrapado" o pierde el contexto:
import { useEffect, useRef } from "react";
export function Modal({ open, onClose, title }: ModalProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) closeRef.current?.focus();
}, [open]);
if (!open) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">{title}</h2>
<button ref={closeRef} onClick={onClose}>Cerrar</button>
</div>
);
}Skip links
Un skip link permite saltar la navegación e ir directo al contenido. Es el primer enlace de la página, normalmente oculto hasta recibir foco:
<a href="#main" className="sr-only focus:not-sr-only">
Saltar al contenido
</a>
<main id="main">…</main>:focus-visible
:focus-visible muestra el anillo de foco solo cuando se navega con teclado, no al hacer clic. Es la forma moderna de no eliminar el foco visible:
/* Mal: elimina el foco para todos */
button:focus { outline: none; }
/* Bien: solo oculta el anillo cuando se usa el ratón */
button:focus:not(:focus-visible) { outline: none; }
button:focus-visible { outline: 2px solid #2563eb; }outline sin reemplazarlo. El anillo de foco es la única señal visual de posición para los usuarios de teclado.Contraste y color
El contraste entre texto y fondo debe cumplir ratios WCAG:
- Texto normal: ≥ 4.5:1 (AA).
- Texto grande (≥ 18px o 14px bold): ≥ 3:1.
- Componentes de UI y gráficos: ≥ 3:1.
Además, el color no debe ser la única señal de un estado. Un enlace que solo cambia de color es invisible para un daltónico; añade subrayado o un icono.
prefers-reduced-motion
Respeta la preferencia del usuario de reducir movimiento. La media query prefers-reduced-motion permite desactivar animaciones:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}Testing de accesibilidad
El testing automatizado detecta una parte de los problemas; el manual cubre el resto. Combina ambos.
Herramientas automatizadas
- axe-core: el motor de detección más usado. Se integra en DevTools y en tests.
- Lighthouse a11y: auditoría de accesibilidad dentro de Lighthouse.
- jest-axe: ejecuta axe en tests de componentes.
- eslint-plugin-jsx-a11y: detecta problemas en el código JSX (labels, roles, alt).
// jest-axe en un test de componente
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
it("no tiene violaciones de accesibilidad", async () => {
const { container } = render(<LoginForm />);
expect(await axe(container)).toHaveNoViolations();
});Pruebas manuales
La prueba manual más valiosa: navega toda la app solo con teclado. Si no puedes completar una tarea sin ratón, hay un problema. También prueba con un lector de pantalla (VoiceOver en macOS, NVDA en Windows) y con zoom al 200%.
Ejercicios
La accesibilidad no es una lista de requisitos: es una forma de pensar el producto. Empieza por el HTML semántico, añade ARIA solo donde hace falta, respeta el teclado y el contraste, y automatiza el testing para que los errores no vuelvan. Al final, una web accesible es una web mejor para todos.