Saltar al contenido principal
50 min de lectura3 de agosto de 2026
Accesibilidada11yWCAGARIAFrontend

Requisitos previos

  • html-css

Lo que aprenderás

  • Entender los principios POUR y los niveles de WCAG 2.2
  • Construir con HTML semántico y landmarks
  • Usar ARIA solo cuando es necesario
  • Crear formularios y modales accesibles por teclado
  • Automatizar tests de accesibilidad con axe
Tutorial50 min

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.
La accesibilidad beneficia a todos: los subtítulos ayudan en entornos ruidosos, el alto contraste ayuda con el sol, y el teclado es esencial para desarrolladores y power users. Es un caso clásico de diseño universal.

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.

No persigas AAA en todo: algunos criterios AAA (como el contraste extremo) degradan la experiencia de otros usuarios. El objetivo realista y estándar es 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.

No elijas el nivel de encabezado por el tamaño visual. Si un 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" />
El 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.

La primera regla de ARIA: si existe un elemento HTML que hace lo que necesitas, úsalo. Un 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>
Usa 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>}
No uses solo el color para indicar el error: añade un icono y un texto. El color no es accesible para personas con daltonismo ni para lectores de pantalla.

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.
Nunca uses 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>
  );
}

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; }
Nunca elimines el 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;
  }
}
El movimiento excesivo puede provocar mareos o convulsiones en personas con sensibilidad vestibular o epilepsia fotosensible. Las animaciones deben ser opcionales, no obligatorias.

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

El testing de accesibilidad es una pirámide: lint en el editor, axe en CI, Lighthouse en cada release y una prueba manual con teclado y lector de pantalla antes de publicar. Cada capa atrapa un tipo distinto de error.

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.

Siguiente lección

Testing frontend con Vitest, Testing Library y Playwright

Aprende a testear aplicaciones frontend: tests unitarios con Vitest, tests de componentes con Testing Library, mocks, tests de integración, E2E con Playwright y cobertura de código.