StackPractices
intermediate Por Mathias Paulenko

WCAG 2.2 Accesibilidad: Guía para Desarrolladores

Una guía práctica de cumplimiento WCAG 2.2: los cuatro principios POUR, ARIA, navegación por teclado, formularios y modales accesibles, con ejemplos de código reales.

Temas: frontend

Resumen

La accesibilidad web (a11y) significa construir sitios que las personas con discapacidad pueden usar de verdad — percibiendo el contenido, navegándolo y completando tareas ya sea con ratón, teclado, lector de pantalla o display braille. WCAG 2.2 es el estándar internacional actual, organizado en torno a cuatro principios: Perceptible, Operable, Comprensible y Robusto (POUR).

Esta guía recorre los criterios que más importan en el trabajo frontend del día a día — HTML semántico, ARIA, navegación por teclado, formularios, modales y contraste — con código que puedes adaptar directamente. Además es un tema legal: ADA, Section 508, EN 301 549 y el European Accessibility Act referencian WCAG, así que “ya lo veremos luego” es una decisión de riesgo, no un item del backlog. Cuando quieras verificar un sitio completo, la Checklist de Auditoría de Accesibilidad convierte lo que enseña esta guía en una auditoría documentada.

Cuándo Usarla

  • Construyes sitios o aplicaciones web de acceso público.
  • Necesitas cumplir requisitos legales (ADA, EAA, Section 508, EN 301 549).
  • Quieres mejorar la usabilidad para todos los usuarios, incluidos los de lector de pantalla y teclado.
  • Te preparas para una auditoría de accesibilidad o remedias sus hallazgos.
  • Construyes un design system o librería de componentes que otros heredarán.

Los Cuatro Principios (POUR)

PrincipioQué significaCriterios clave
PerceptibleLa información debe poder percibirseAlt text, contraste de color, texto redimensionable
OperableLos componentes deben ser operables por todosNavegación por teclado, indicadores de foco, tiempos
ComprensibleLa información y la operación deben ser comprensiblesTexto legible, comportamiento predecible, prevención de errores
RobustoEl contenido debe funcionar con tecnología asistiva actual y futuraHTML válido, roles ARIA, name-role-value

Los principios no son una lista a completar una vez — son una lente para cada componente que construyes. Un selector de fecha que se ve perfecto pero no se alcanza por teclado falla en Operable. Un formulario que anuncia errores solo poniéndolos en rojo falla en Perceptible. La mayoría de fallos reales toca exactamente un principio, lo que hace el framework útil tanto para triagear bugs como para auditorías.

Los cuatro principios de WCAG 2.2 parten del estándar: Perceptible cubre alt text, contraste y redimensionado; Operable cubre teclado, foco y tiempos; Comprensible cubre interfaces legibles y predecibles; Robusto cubre HTML válido y ARIA.

HTML Semántico Primero

La accesibilidad más barata es la que viene gratis. Los elementos nativos llevan semántica, soporte de teclado y comportamiento de foco integrados; <div> no lleva nada. Los lectores de pantalla anuncian <nav> como navegación y <button> como botón — un <div class="nav"> no anuncia nada.

<!-- Mal: sopa de divs — los lectores de pantalla no ven nada -->
<div class="header">
  <div class="nav">
    <div class="link" onclick="navigate('/')">Home</div>
  </div>
</div>

<!-- Bien: elementos semánticos — los lectores entienden la estructura -->
<header>
  <nav aria-label="Navegación principal">
    <a href="/">Home</a>
  </nav>
</header>

<!-- Los elementos semánticos traen accesibilidad integrada -->
<main>
  <article>
    <header>
      <h1>Título del Artículo</h1>
      <time datetime="2026-07-05">5 de julio de 2026</time>
    </header>
    <section>
      <h2>Encabezado de Sección</h2>
      <p>Contenido...</p>
    </section>
    <aside>
      <h2>Relacionado</h2>
      <ul>
        <li><a href="/related-1">Artículo Relacionado 1</a></li>
      </ul>
    </aside>
  </article>
</main>

Los encabezados merecen la misma disciplina: <h1> a <h6> son la tabla de contenidos del documento para la tecnología asistiva. Saltar de <h2> a <h5> se lee para un usuario de lector de pantalla como un libro que salta capítulos — mantén la jerarquía secuencial y usa CSS para el tamaño visual.

ARIA: Cuando el HTML No Basta

ARIA (Accessible Rich Internet Applications) añade información semántica a elementos que la necesitan — pero la primera regla de ARIA es no usarla si existe un elemento nativo. Un <div role="button"> necesita tabindex, manejadores de teclado y atributos ARIA para hacer la mitad de lo que <button> hace de serie.

<!-- Mal: recrear un botón -->
<div role="button" tabindex="0" onclick="submit()">Enviar</div>

<!-- Bien: el elemento que ya es un botón -->
<button onclick="submit()">Enviar</button>

ARIA es para el hueco: widgets personalizados (pestañas, árboles, comboboxes), regiones live y estado que el HTML nativo no puede expresar:

<!-- aria-label: cuando no hay texto visible -->
<button aria-label="Cerrar diálogo" onclick="closeDialog()">
  <svg aria-hidden="true"><use href="#icon-close" /></svg>
</button>

<!-- aria-labelledby: cuando texto visible etiqueta el elemento -->
<input type="search" aria-labelledby="search-label" />
<span id="search-label">Buscar productos</span>

<!-- aria-describedby: descripción adicional -->
<input type="password" aria-describedby="password-hint" />
<p id="password-hint">Mínimo 12 caracteres con un número y un símbolo.</p>

<!-- aria-live: anunciar cambios de contenido dinámico -->
<div aria-live="polite" id="status"></div>

<!-- aria-expanded: estado de alternancia -->
<button aria-expanded="false" aria-controls="menu" onclick="toggleMenu()">
  Menú
</button>
<ul id="menu" hidden>
  <li><a href="/settings">Configuración</a></li>
  <li><a href="/logout">Cerrar sesión</a></li>
</ul>

<!-- aria-hidden: ocultar contenido decorativo del lector de pantalla -->
<svg aria-hidden="true" class="icon"><path d="..." /></svg>

Dos trampas a evitar: ARIA añade semántica, nunca comportamiento — role="button" no hace un div clicable, el teclado y los handlers los cableas tú. Y ARIA incorrecto es peor que ninguno: un widget mal etiquetado engaña activamente al usuario en lugar de simplemente no decir nada.

Cómo Se Computan los Nombres Accesibles

Todo elemento interactivo necesita un nombre que el lector de pantalla pueda anunciar — el “nombre accesible”. El navegador lo computa desde una lista de prioridad que conviene conocer porque explica la mayoría de los bugs de “¿por qué este botón no dice nada?”:

  1. aria-labelledby — gana a todo; apunta a texto visible en otro sitio.
  2. aria-label — siguiente prioridad; sobrescribe el contenido del propio elemento.
  3. Contenido o <label> asociado — texto del enlace, texto del botón, alt, <label for> — el fallback.
  4. title — último recurso; no confíes en él.
<!-- Anuncia "Buscar productos" — aria-labelledby gana -->
<input type="search" aria-label="Búsqueda del sitio" aria-labelledby="search-label" />
<span id="search-label">Buscar productos</span>

<!-- Anuncia "Búsqueda del sitio" — aria-label supera al contenido -->
<button aria-label="Búsqueda del sitio">Clic aquí</button>

La consecuencia práctica: aria-label reemplaza el texto visible para los lectores de pantalla, no lo aumenta. aria-label="Búsqueda del sitio" en un botón que dice “Enviar” anuncia “Búsqueda del sitio” — genial cuando es intencional, confuso cuando se copia a ciegas. Y un <label> envolviendo un input (o enlazado con for/id) es casi siempre la respuesta correcta para formularios; recurre a aria-label solo cuando ningún texto visible puede hacer el trabajo.

La Regla de Nombre Accesible vs Texto Visible (2.5.3)

Un criterio en el que la gente tropieza: lo que diga un aria-label debe contener el texto visible. Un botón que muestra “Eliminar” etiquetado aria-label="Quitar elemento" anuncia “Quitar elemento” — un usuario de control por voz que dice “clic en Eliminar” no puede activarlo. Mantén las primeras palabras del nombre accesible coincidiendo con la etiqueta visible y esto nunca te muerde.

Esta disciplina de nombrado escala con el número de componentes: una página de controles bien nombrados se lee para un usuario de lector de pantalla como un panel etiquetado; una página de enlaces “clic aquí” e iconos sin etiqueta se lee como un muro de botones. Es la misma prueba que el contraste — invisible hasta que escuchas tu propia UI.

Perceptible

Alternativas de Texto (1.1.1)

Toda imagen significativa necesita alternativa de texto; las decorativas necesitan una vacía.

<!-- Bien: alt text descriptivo -->
<img src="chart.png" alt="Gráfico de barras mostrando crecimiento de ingresos Q1-Q4 de 2M a 5M" />

<!-- Bien: imagen decorativa oculta del lector de pantalla -->
<img src="decoration.png" alt="" />

<!-- Mal: alt text ausente o inútil -->
<img src="chart.png" />
<img src="chart.png" alt="imagen" />

La distinción que importa: las imágenes informativas llevan alt text que transmite el punto de la imagen (¿qué muestra el gráfico?), mientras las puramente decorativas llevan alt="" para que el lector las salte. Un icono junto a una etiqueta de texto es decorativo; un gráfico en un informe es contenido.

Contraste de Color (1.4.3, 1.4.11)

El texto debe tener contraste suficiente contra su fondo — el fallo WCAG más común de la web.

NivelTexto normalTexto grandeComponentes UI
AA4.5:13:13:1
AAA7:14.5:1—
/* Buen contraste (cumple AA sobre blanco) */
.text-primary { color: #1e293b; }  /* 14.7:1 */
.text-secondary { color: #475569; } /* 7.6:1 */
.text-muted { color: #64748b; }    /* 4.6:1 */

/* Mal contraste (falla AA) */
.text-faint { color: #94a3b8; }    /* 2.9:1 — falla para texto normal */

/* El indicador de foco debe ser 3:1 contra colores adyacentes */
button:focus-visible {
  outline: 3px solid #2563eb;     /* azul sobre blanco: 5.2:1 */
  outline-offset: 2px;
}

Y el contraste no es solo texto: botones de icono, bordes de formulario e indicadores de foco necesitan 3:1 contra colores adyacentes bajo 1.4.11. “Gris sobre blanco” es donde esto suele romperse — #999 es 2.8:1 y falla; #767676 es 4.5:1 y pasa.

Medios Temporales (1.2.x)

El vídeo y el audio necesitan alternativas para quienes no pueden oírlos o verlos:

<!-- Subtítulos para vídeo pregrabado (1.2.2) -->
<video controls>
  <source src="demo.mp4" type="video/mp4" />
  <track kind="captions" src="captions.vtt" srclang="es" label="Español" default />
</video>

<!-- Audiodescripción o transcripción completa para vídeo informativo (1.2.5) -->
<details>
  <summary>Transcripción</summary>
  <p>La demo abre en el dashboard mostrando cuatro métricas...</p>
</details>

Los subtítulos y las transcripciones son baratos de producir hoy — el auto-subtitulado te lleva al 90%, la revisión humana corrige nombres y jerga. Un vídeo demo de cinco minutos sin transcribir son cinco minutos de contenido que no existe para un usuario sordo.

Texto Redimensionable (1.4.4) y Reflujo (1.4.10)

El texto debe sobrevivir un zoom del 200% y el contenido debe refluir a 320px sin scroll horizontal.

/* Bien: unidades relativas respetan la configuración del usuario */
body { font-size: 100%; }
h1 { font-size: 2rem; }

/* Mal: píxeles fijos que rompen el zoom */
body { font-size: 16px; }

Operable

Accesible por Teclado (2.1.1)

Toda la funcionalidad debe funcionar desde el teclado — sin ratón, nunca.

<!-- Bien: elementos nativos accesibles por teclado -->
<button onclick="submit()">Enviar</button>
<a href="/next">Página Siguiente</a>

<!-- Mal: div fingiendo ser un botón -->
<div class="btn" onclick="submit()">Enviar</div>

<!-- Bien: componente custom con soporte de teclado -->
<div role="button" tabindex="0"
     onclick="submit()"
     onkeydown="if(event.key==='Enter'||event.key===' ') submit()">
  Enviar
</div>

Foco Visible (2.4.7)

El foco de teclado debe ser visible en todo momento — es como los usuarios de teclado saben dónde están en la página.

/* Nunca quitar el indicador de foco sin reemplazo */
*:focus {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* Estilo de foco custom para elementos interactivos */
button:focus-visible,
a:focus-visible {
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.4);
}

Saltar Bloques (2.4.1)

Ofrece una forma de saltar contenido repetitivo — los usuarios de teclado no deberían tabular por treinta enlaces de nav en cada página.

.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #2563eb;
  color: white;
  padding: 8px 16px;
  z-index: 100;
  transition: top 0.2s;
}
.skip-link:focus { top: 0; }
<a href="#main" class="skip-link">Saltar al contenido principal</a>
<nav>...</nav>
<main id="main"><h1>Contenido de la página</h1></main>

Regiones Live y Contenido Dinámico (4.1.3)

Cuando el contenido cambia sin recargar la página — resultados de formulario, notificaciones toast, contadores de búsqueda, cambios de ruta en una SPA — los usuarios de lector de pantalla no lo ven salvo que algo lo anuncie. Las regiones aria-live son ese algo.

<!-- Polite: se anuncia cuando el usuario está ocioso — estados, contadores -->
<div aria-live="polite" id="status"></div>

<!-- Assertive: interrumpe inmediatamente — errores, alertas urgentes -->
<div role="alert" id="error-banner"></div>
// Anunciar una actualización dinámica
function showStatus(message: string) {
  const status = document.getElementById("status");
  status!.textContent = message;
}

// Cambios de ruta en SPA: anunciar la nueva página
function announceRoute(title: string) {
  document.title = title;
  const announcer = document.getElementById("route-announcer");
  announcer!.textContent = `Navegado a ${title}`;
  // Y mover el foco al h1 o landmark main de la nueva página
}

Dos reglas mantienen las regiones live usables: prefiere polite salvo que sea genuinamente urgente (los anuncios assertive cortan lo que el lector estaba diciendo), y pon la región en el HTML inicial — las regiones inyectadas tras la carga no se observan fiablemente. Los anuncios de ruta más la gestión de foco en la navegación son la diferencia entre una SPA que se narra como un sitio web y una donde el usuario pierde el sitio en cada click.

Movimiento y Animación (2.2.2, 2.3.3)

El contenido con autoplay, destellos o parallax necesita respetar al usuario. El contenido que destella más de tres veces por segundo es un riesgo de convulsiones — un requisito duro, no una preferencia — y prefers-reduced-motion permite al usuario optar por no tener movimiento decorativo:

/* Respeta la preferencia de movimiento del usuario */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

El override completo de arriba es la versión directa; el enfoque refinado conserva la animación funcional (indicadores de carga, progreso) y solo mata la decorativa (parallax, carruseles automáticos, animaciones de entrada).

Roving Tabindex para Widgets Personalizados

Los widgets compuestos como pestañas y barras de herramientas mantienen un ítem en el orden de tab y mueven el foco con flechas — el patrón que hace usable el teclado de componentes.

// Interfaz de pestañas: solo una pestaña está en el orden de tab a la vez
class TabList {
  private tabs: HTMLElement[] = [];
  private activeIndex = 0;

  constructor(container: HTMLElement) {
    this.tabs = Array.from(container.querySelectorAll('[role="tab"]'));
    this.tabs.forEach((tab, index) => {
      tab.addEventListener("keydown", this.handleKeyDown.bind(this));
      tab.addEventListener("click", () => this.activate(index));
    });
    this.updateTabindex();
  }

  private updateTabindex() {
    this.tabs.forEach((tab, index) => {
      tab.tabIndex = index === this.activeIndex ? 0 : -1;
      tab.setAttribute("aria-selected", String(index === this.activeIndex));
    });
  }

  private activate(index: number) {
    this.activeIndex = index;
    this.updateTabindex();
    this.tabs[index].focus();
  }

  private handleKeyDown(e: KeyboardEvent) {
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        this.activate((this.activeIndex + 1) % this.tabs.length);
        break;
      case "ArrowLeft":
        e.preventDefault();
        this.activate((this.activeIndex - 1 + this.tabs.length) % this.tabs.length);
        break;
      case "Home":
        e.preventDefault();
        this.activate(0);
        break;
      case "End":
        e.preventDefault();
        this.activate(this.tabs.length - 1);
        break;
    }
  }
}

Comprensible

Texto Legible (3.1.5) e Idioma (3.1.1)

El contenido debe ser legible y estar marcado con su idioma para que los lectores lo pronuncien correctamente.

<!-- Declarar el idioma de la página -->
<html lang="es">

<!-- Bien: lenguaje claro y simple -->
<p>Introduce tu email para restablecer tu contraseña.</p>

<!-- Mal: jerga y complejidad -->
<p>Introduce tu dirección de correo electrónico registrada para iniciar el protocolo de recuperación de credenciales.</p>

Comportamiento Predecible (3.2.x)

Las interfaces no deberían cambiar de contexto sin aviso — nada de envíos sorpresa al enfocar, ni navegación que se reordena entre páginas. La navegación consistente y las respuestas predecibles son criterios, no preferencias.

Prevención de Errores (3.3.4)

Previene errores en envíos legales, financieros o de modificación de datos — pasos de confirmación y pantallas de revisión.

<!-- Bien: confirmación para acciones destructivas -->
<form onsubmit="return confirm('¿Eliminar esta cuenta permanentemente?')">
  <button type="submit">Eliminar Cuenta</button>
</form>

Robusto

HTML Válido y Name, Role, Value (4.1.2)

La tecnología asistiva parsea tu markup — HTML válido y roles ARIA correctos son cómo los componentes anuncian qué son a un lector de pantalla.

<!-- Bien: elemento semántico nativo con nombre accesible -->
<nav aria-label="Navegación principal">
  <ul>
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/about">Acerca de</a></li>
  </ul>
</nav>

<!-- Bien: ARIA para componentes personalizados -->
<div role="tablist">
  <button role="tab" aria-selected="true" id="tab-1">Pestaña 1</button>
  <button role="tab" aria-selected="false" id="tab-2">Pestaña 2</button>
</div>
<div role="tabpanel" aria-labelledby="tab-1">Contenido del panel</div>

Formularios Accesibles

Los formularios son donde los fallos de accesibilidad cuestan conversiones — cada campo sin etiqueta es un usuario perdido en el último paso. El patrón completo: un <label> visible por cada input, campos requeridos marcados en texto (no solo en color), controles agrupados en <fieldset>/<legend> y errores anunciados programáticamente.

<form>
  <!-- Cada input tiene su label -->
  <div>
    <label for="name">Nombre Completo</label>
    <input type="text" id="name" name="name" required autocomplete="name" />
  </div>

  <!-- Campos requeridos marcados con texto, no solo color -->
  <div>
    <label for="email">
      Email <span aria-hidden="true" class="required">*</span>
      <span class="sr-only">(requerido)</span>
    </label>
    <input
      type="email"
      id="email"
      name="email"
      required
      autocomplete="email"
      aria-describedby="email-error"
      aria-invalid="false"
    />
    <p id="email-error" class="error" role="alert"></p>
  </div>

  <!-- Controles agrupados con fieldset/legend -->
  <fieldset>
    <legend>Preferencias de Notificación</legend>
    <label><input type="checkbox" name="notify" value="email" /> Email</label>
    <label><input type="checkbox" name="notify" value="sms" /> SMS</label>
  </fieldset>

  <button type="submit">Crear Cuenta</button>
</form>

El manejo de errores necesita dos piezas: aria-invalid en el campo que falla y un canal de anuncio (role="alert" o aria-live) para que el error llegue al usuario, no solo a la pantalla.

// Validación de formulario accesible
function validateField(field: HTMLInputElement): boolean {
  const errorEl = document.getElementById(`${field.id}-error`);
  const isValid = field.checkValidity();

  field.setAttribute("aria-invalid", String(!isValid));

  if (!isValid && errorEl) {
    errorEl.textContent = field.validationMessage;
    errorEl.style.display = "block";
  } else if (errorEl) {
    errorEl.textContent = "";
    errorEl.style.display = "none";
  }

  return isValid;
}

Diálogo Modal Accesible

Los modales son la trampa de teclado clásica. Uno accesible enfoca hacia dentro al abrirse, mantiene el foco dentro mientras está abierto (la “trampa”), cierra con Escape y devuelve el foco al disparador al cerrarse. El elemento nativo <dialog> maneja la mayor parte de esto.

<dialog aria-labelledby="modal-title" aria-describedby="modal-desc">
  <h2 id="modal-title">Confirmar Eliminación</h2>
  <p id="modal-desc">¿Seguro que quieres eliminar este elemento? Esta acción no se puede deshacer.</p>
  <div class="actions">
    <button onclick="modal.close()">Cancelar</button>
    <button onclick="confirmDelete()">Eliminar</button>
  </div>
</dialog>
class AccessibleModal {
  private dialog: HTMLDialogElement;
  private previouslyFocused: HTMLElement | null = null;

  constructor(dialog: HTMLDialogElement) {
    this.dialog = dialog;
    dialog.addEventListener("close", this.onClose.bind(this));
    dialog.addEventListener("keydown", this.onKeyDown.bind(this));
  }

  open() {
    this.previouslyFocused = document.activeElement as HTMLElement;
    this.dialog.showModal();
    const firstFocusable = this.dialog.querySelector(
      "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
    ) as HTMLElement;
    firstFocusable?.focus();
  }

  close() {
    this.dialog.close();
  }

  private onClose() {
    // Devolver el foco al elemento que abrió el modal
    this.previouslyFocused?.focus();
  }

  private onKeyDown(e: KeyboardEvent) {
    if (e.key === "Escape") {
      this.close();
      return;
    }

    // Trampa de foco: mantener el foco dentro del diálogo
    if (e.key !== "Tab") return;

    const focusables = Array.from(
      this.dialog.querySelectorAll<HTMLElement>(
        "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
      )
    ).filter((el) => !el.hasAttribute("disabled"));

    if (focusables.length === 0) return;

    const first = focusables[0];
    const last = focusables[focusables.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }
}

Contenido Solo para Lector de Pantalla

A veces el diseño visual no puede cargar la información que el lector necesita — botones de icono, contadores de ítems, texto de estado. El patrón .sr-only oculta contenido visualmente manteniéndolo en el árbol de accesibilidad.

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Mostrar visualmente al enfocar (p.ej. enlace de salto) */
.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
<!-- Botón de icono con nombre accesible -->
<button>
  <span aria-hidden="true">🗑</span>
  <span class="sr-only">Eliminar elemento</span>
</button>

<!-- Contexto de posición en una lista -->
<ul>
  <li>Elemento 1 <span class="sr-only">de 5</span></li>
  <li>Elemento 2 <span class="sr-only">de 5</span></li>
</ul>

Probar la Accesibilidad

La prueba de tres capas de la checklist de auditoría aplica aquí también: las herramientas automatizadas hacen triage, la prueba manual/teclado encuentra fallos de interacción, la de lector de pantalla encuentra los semánticos.

HerramientaPropósito
axe DevToolsExtensión de navegador para chequeos automáticos
LighthouseAuditoría de accesibilidad en Chrome
axe-core + PlaywrightChequeos automatizados en CI
NVDA / JAWSPruebas con lector de pantalla (Windows)
VoiceOverPruebas con lector de pantalla (macOS)
Solo tecladoPrueba manual de navegación
WAVEHerramienta de evaluación de WebAIM

Las herramientas automatizadas atrapan el 30-40% de los problemas — los mecánicos. El resto necesita los pases humanos: teclado por cada flujo, lector de pantalla por los críticos, zoom 200% y reflujo a 320px para los criterios visuales. La Checklist de Auditoría de Accesibilidad estructura ese proceso cuando necesitas que sea repetible y auditable, y la Plantilla de Matriz de Soporte de Navegadores registra qué parejas navegador/lector cubriste de verdad — NVDA+Firefox y VoiceOver+Safari exponen fallos distintos a NVDA+Chrome.

Para exigencia continua, axe-core en la suite de tests atrapa regresiones en tiempo de PR — el 40% mecánico se vuelve auto-exigible y la auditoría puede enfocarse en lo que las máquinas no pueden juzgar:

test("homepage pasa WCAG", async ({ page }) => {
  await page.goto("https://shop.example.com");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();
  expect(results.violations).toEqual([]);
});

Novedades de WCAG 2.2

Nueve criterios se incorporaron en 2.2 — conviene conocerlos porque las auditorías escritas para 2.1 los pierden:

  • 2.4.11 Focus Not Obscured — los elementos enfocados no pueden quedar totalmente ocultos tras headers fijos u overlays.
  • 2.4.13 Focus Appearance (AAA) — tamaño y contraste mínimos para el indicador de foco mismo.
  • 2.5.7 Dragging Movements — arrastrar-soltar necesita alternativa de un solo puntero.
  • 2.5.8 Target Size — objetivos de puntero de al menos 24×24 píxeles CSS (con excepciones de espaciado).
  • 3.2.6 Consistent Help — mecanismos de ayuda en el mismo sitio en todas las páginas.
  • 3.3.7 Redundant Entry — no pedir al usuario reintroducir datos que ya tienes.
  • 3.3.8 Accessible Authentication — sin pruebas cognitivas (tests de memoria, transcripción tipo CAPTCHA) para iniciar sesión.

El patrón en las adiciones: menos tolerancia a interacciones que asumen ratón, memoria o vista despejada. Si tu última auditoría fue contra 2.1, estas son las filas a añadir.

Accesibilidad en el Ciclo de Desarrollo

El sitio más barato para arreglar la accesibilidad es el momento en que se decide. Cada etapa del pipeline tiene un coste distinto y atrapa una categoría distinta de fallo.

Diseño. El contraste, los estados de foco y el orden de lectura son decisiones de diseño antes de ser código. Un mockup revisado por accesibilidad — ¿hay estilo de foco? ¿ese gris pasa 4.5:1? ¿el flujo se opera sin ratón? — atrapa fallos casi gratis de cambiar en Figma y caros de cambiar cuando un design system los ha llevado a cuarenta páginas. Los tokens son donde más rinde: arregla el token de texto atenuado una vez, arréglalo en todas partes.

Componente. ¿Construyes un componente? Pruébalo antes de que salga — un pase de teclado y uno de lector de pantalla en la story page. Un selector de fecha accesible en la librería es accesible dondequiera que se use; lo contrario también es cierto. La prueba a nivel componente es donde el roving tabindex, las trampas de foco y las regiones live se prueban una vez en lugar de re-fallar en cada página.

Pull request. axe-core en CI atrapa las regresiones mecánicas — alt ausente, labels sin asociar, ARIA inválido — en el punto del cambio, cuando el developer aún tiene contexto. No puede juzgar significado (para eso están los pases humanos de la checklist), pero hace que el 40% mecánico se auto-exija.

Auditoría. La auditoría trimestral o pre-release verifica el conjunto: flujos, interacciones, las partes que la automatización no ve. La Checklist de Auditoría de Accesibilidad existe para esta etapa — la guía enseña a construirlo bien; la checklist verifica que lo hiciste.

El patrón a lo largo de las cuatro: la accesibilidad es una propiedad del código, no una fase que se emperra al final. Los equipos que la tratan como actividad de auditoría pasan cada ciclo remediando; los que la integran en el ciclo de vida invierten las mismas horas previniendo.

Patrones Comunes de Accesibilidad que Conviene Conocer

Un puñado de patrones cubre la mayoría de lo que necesitan las apps reales. Más allá del formulario, el modal y las regiones live de arriba, los habituales:

  • Skip links para saltar la navegación (mostrado arriba en Operable).
  • aria-current="page" en el ítem de nav activo — los lectores anuncian “página actual” en lugar del texto del enlace.
  • Estados de carga: un spinner que no anuncia nada es un callejón sin salida para lectores — acompáñalo de un estado aria-live (“Cargando resultados…”).
  • Tablas de datos: headers <th scope="col"> para que los lectores anuncien la columna de cada celda; las tablas complejas llevan cableado headers/id.
  • Botones solo-icono: siempre un aria-label o texto .sr-only — “botón, botón, botón” es como suena una fila de iconos sin etiquetar.
  • Notificaciones toast: role="status" con aria-live="polite" para que el mensaje llegue al usuario; las alertas críticas llevan role="alert".
  • Enlaces externos: avisa cuando un enlace abre ventana nueva (target="_blank" + texto oculto “(abre en pestaña nueva)”) — el cambio de contexto sorpresa falla 3.2.x para algunos usuarios.

La mayoría son tres líneas de markup. Lo caro es acordarse de que existen — para eso sirve la librería de componentes: codifícalos una vez en los componentes compartidos y cada página hereda el arreglo.

Errores Comunes

  • Quitar los outlines de foco: * { outline: none } hace imposible la navegación por teclado — siempre con reemplazo visible.
  • Usar divs como botones: <div onclick> no es accesible por teclado; usa <button>.
  • Labels de formulario ausentes: inputs sin label no tienen nombre para los lectores — los placeholders no cuentan.
  • Indicación de error solo por color: el texto rojo solo es invisible para usuarios daltónicos; añade iconos y texto.
  • Sin alt text en imágenes: los lectores leen el nombre del archivo; alt descriptivo o alt="" para decorativas.
  • Saltos en la jerarquía de encabezados: saltar de <h1> a <h4> rompe el esquema del documento; no saltes niveles.
  • ARIA añadido sin probar: roles erróneos engañan a los usuarios — verifica con un lector de pantalla real.
  • Media con autoplay sin controles: respeta prefers-reduced-motion y los controles de pausa.
  • Scroll infinito sin mecanismo de salto: ofrece paginación o búsqueda como alternativas.
  • Overlays como arreglo de cumplimiento: los widgets de accesibilidad no arreglan el markup subyacente — arregla el código.

Lecturas Adicionales

Preguntas frecuentes

¿A qué nivel de WCAG debería apuntar?

El nivel AA es el estándar para la mayoría de requisitos legales — ADA, Section 508, EN 301 549 y el EAA referencian conformidad AA. El nivel AAA es aspiracional para contenido crítico y raramente alcanzable en todo un sitio.

¿Qué es ARIA?

Accessible Rich Internet Applications — un conjunto de atributos HTML que añaden información de accesibilidad a widgets personalizados. ARIA no añade comportamiento, solo comunica semántica a las tecnologías asistivas. Úsala cuando la semántica nativa de HTML no baste; los elementos nativos siempre son preferibles.

¿Necesito probar con lectores de pantalla reales?

Sí. Las herramientas automatizadas atrapan aproximadamente un tercio de los problemas — los mecánicos. La lógica del orden de foco, la calidad de los anuncios y el orden de lectura solo se revelan con NVDA, VoiceOver o JAWS corriendo contra la página real.

¿Cuál es la diferencia entre WCAG 2.1 y 2.2?

WCAG 2.2 añade nueve criterios de éxito — principalmente sobre apariencia de foco, arrastre, tamaño de objetivo y autenticación — y elimina 4.1.1 Parsing. Si cumples 2.1, los nueve criterios nuevos son tu delta para cumplir 2.2.

¿Cómo pruebo la accesibilidad?

Tres capas: herramientas automatizadas (axe, Lighthouse) para chequeos mecánicos, prueba solo-teclado para interacción, y pases con lector de pantalla para semántica. Luego zoom 200% y ancho de 320px para los criterios visuales. La checklist de auditoría lo estructura de extremo a extremo.

¿Puedo usar un widget overlay en lugar de arreglar el código?

No. Los overlays como AccessiBe o UserWay inyectan JavaScript que promete arreglar la accesibilidad dinámicamente — no reparan el markup subyacente, pueden interferir con lectores de pantalla y los tribunales no los han aceptado como cumplimiento. El arreglo está en el código.

¿Qué ratio de contraste de color necesito?

WCAG AA exige 4.5:1 para texto normal, 3:1 para texto grande (18pt+ o 14pt negrita) y 3:1 para componentes UI e indicadores de foco. AAA sube el texto a 7:1/4.5:1.