Skip to content

🎯 Meta: que tus interfaces las pueda usar todo el mundo — teclado, lector de pantalla, baja visión, motricidad reducida — y que además se sientan bien: focus correcto, animaciones con propósito y View Transitions. La accesibilidad no es una feature: es que el producto funcione. Y en la UE es ley (European Accessibility Act, en vigor desde jun-2025).

Estándar: WCAG 2.2 nivel AA (el objetivo legal y profesional de facto).


M.1 · El 80% de la accesibilidad es HTML bien escrito

La mayoría de problemas de a11y no se arreglan "añadiendo ARIA": se arreglan quitando divs.

html
<!-- ❌ El clásico div-clickeable: invisible para teclado y lector de pantalla -->
<div class="btn" onclick="comprar()">Comprar</div>

<!-- ✅ Un botón de verdad: focusable, activable con Enter/Espacio, anunciado como botón -->
<button onclick="comprar()">Comprar</button>
NecesitasUsaNunca
Acción en la página<button><div onclick> / <a href="#">
Navegar a otra URL<a href><span> con router push
Campo de formulario<input> + <label for>placeholder como única etiqueta
Estructura<nav> <main> <h1>-<h6> en ordendivs con clases "heading"
html
<!-- Formularios accesibles: label SIEMPRE, errores conectados -->
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email"
       aria-describedby="email-error" aria-invalid="true">
<p id="email-error" role="alert">El email no es válido</p>

🧠 Primera regla de ARIA: no uses ARIA (si existe el elemento nativo). <button> trae gratis lo que role="button" + tabindex="0" + handlers de teclado intentan imitar mal. ARIA es para lo que HTML no cubre: tabs, comboboxes, live regions.

Cuando SÍ hace falta ARIA: el patrón combobox

El buscador con autocompletado (ap. T) es el ejemplo real donde no hay elemento nativo que sirva — HTML no tiene "input con lista de sugerencias navegable por teclado". Aquí manda el patrón combobox del ARIA Authoring Practices Guide (APG), no una improvisación:

html
<label for="buscar">Buscar producto</label>
<input id="buscar" role="combobox" type="text"
       aria-expanded="false" aria-controls="lista-sugerencias"
       aria-autocomplete="list" aria-activedescendant="">
<ul id="lista-sugerencias" role="listbox">
  <li id="opcion-1" role="option" aria-selected="false">Teclado mecánico</li>
  <li id="opcion-2" role="option" aria-selected="false">Teclado inalámbrico</li>
</ul>
  • El input lleva role="combobox" + aria-expanded (¿la lista está abierta?) + aria-controls (qué lista gobierna) + aria-activedescendant (qué opción está resaltada, sin mover el focus real del input — el usuario sigue escribiendo).
  • Flechas ↑↓ mueven aria-activedescendant entre las option; Enter selecciona; Esc cierra.
  • El componente no se construye desde cero: librerías como React Aria (Adobe) o Radix UI implementan este patrón exacto, probado con lectores de pantalla reales — te ahorran los detalles finos (¿qué pasa si hay 0 resultados? ¿y con VoiceOver vs NVDA?).

⚠️ Un combobox casero con <input> + <div> de sugerencias y solo onClick es el bug de a11y más común en buscadores: funciona con ratón, es invisible por teclado y un lector de pantalla no sabe que existe una lista. Si vas a construir uno, sigue el patrón APG al pie de la letra o usa una librería que ya lo haga.


M.2 · Teclado y focus: la prueba del ratón desenchufado

Todo lo interactivo debe poder usarse solo con teclado: Tab avanza, Shift+Tab retrocede, Enter/Espacio activan, Esc cierra. Pruébalo hoy en tu app: donde te quedes atascado, hay un bug.

css
/* NUNCA elimines el focus sin reemplazarlo — es el cursor del usuario de teclado */
:focus { outline: none; }                     /* ❌ crimen clásico */

:focus-visible {                              /* ✅ visible para teclado, discreto para ratón */
  outline: 3px solid var(--color-primario);
  outline-offset: 2px;
}

Gestión de focus en SPAs (caps. 17-19) — el navegador ya no te ayuda, te toca a ti:

  • Al abrir un modal: focus al modal; al cerrarlo, focus de vuelta al botón que lo abrió. El elemento nativo <dialog> + showModal() hace la trampa de focus por ti — úsalo.
  • Al cambiar de ruta: mueve el focus al <h1> de la nueva vista (con tabindex="-1"), o el usuario de lector de pantalla no se entera de que navegó.
  • Salta-navegación: primer elemento del <body>:
html
<a class="skip-link" href="#main">Saltar al contenido</a>
tsx
// React: focus al título al cambiar de ruta
const h1Ref = useRef<HTMLHeadingElement>(null);
useEffect(() => { h1Ref.current?.focus(); }, [pathname]);
<h1 ref={h1Ref} tabIndex={-1}>Productos</h1>

M.3 · Lectores de pantalla: nombres, estados y avisos

html
<!-- Botones de icono: SIN texto no hay nombre accesible -->
<button aria-label="Cerrar">✕</button>
<button aria-label="Borrar Teclado mecánico">🗑</button>   <!-- nombre ESPECÍFICO -->

<!-- Imágenes: alt útil, o vacío si es decorativa -->
<img src="teclado.webp" alt="Teclado mecánico RGB de 60%">
<img src="separador.svg" alt="">                            <!-- decorativa: alt="" la silencia -->

<!-- Estados dinámicos -->
<button aria-expanded="false" aria-controls="menu">Menú</button>
<button aria-pressed="true">Favorito</button>

Live regions — anunciar lo que cambia sin mover el focus (el toast, el "3 resultados"):

html
<div aria-live="polite" class="sr-only" id="anunciador"></div>
<!-- JS: anunciador.textContent = "Producto añadido al carrito" → el lector lo lee -->
css
/* sr-only: visible para lectores de pantalla, invisible en pantalla */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

💡 En React, los estados de TanStack Query se anuncian así: un componente <Anunciador> con aria-live que refleja "Cargando…", "12 resultados", "Error al cargar". Cinco líneas que transforman la experiencia con lector de pantalla.


M.4 · Contraste, tamaño y movimiento

  • Contraste (WCAG AA): texto normal ≥ 4.5:1; texto grande ≥ 3:1; componentes UI ≥ 3:1. El gris chic #999 sobre blanco (2.8:1) no pasa. Mídelo en DevTools (inspector de color) o con el plugin axe.
  • El color nunca es el único canal: "los errores en rojo" + icono/texto. Un 8% de los hombres tiene daltonismo.
  • Objetivos táctiles ≥ 24×24 px (WCAG 2.2) — 44×44 recomendado en móvil.
  • Zoom al 200% sin scroll horizontal ni contenido cortado (usa rem, no px, para texto).
  • Movimiento: respeta la preferencia del sistema, siempre:
css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

💡 forced-colors (Windows High Contrast): el sistema operativo puede reemplazar tu paleta entera por unos pocos colores de alto contraste que elige el usuario. No lo combatas con !important; usa forced-color-adjust solo donde de verdad lo necesites y verifica que los bordes/estados siguen siendo distinguibles cuando el color de fondo desaparece:

css
@media (forced-colors: active) {
  .boton-icono { border: 1px solid; }   /* sin borde, un botón "sin fondo" se vuelve invisible */
}

M.5 · Animaciones con propósito (UX, no fuegos artificiales)

Una animación buena explica: de dónde vino algo, a dónde se fue, qué cambió. Reglas:

  1. Anima solo transform y opacity (van por GPU, no re-layoutean). Animar width/top/margin = jank garantizado en móvil.
  2. 150-300 ms para micro-interacciones; más de 500 ms estorba.
  3. Entrada suave, salida rápida: ease-out al aparecer, ease-in al desaparecer.
css
.toast {
  transition: translate 200ms ease-out, opacity 200ms ease-out;
  @starting-style { translate: 0 1rem; opacity: 0; }   /* animar ENTRADA al montar (CSS puro) */
}

View Transitions API: transiciones de página nativas

css
/* Lo básico es CSS: el navegador cruza-funde entre estados */
@view-transition { navigation: auto; }        /* MPAs/HTMX: transición entre páginas ✨ */

/* El "morph" del elemento compartido: la miniatura vuela a la página de detalle */
.card img   { view-transition-name: producto-42; }
.detalle img { view-transition-name: producto-42; }
typescript
// SPA: envuelve el cambio de estado/DOM
document.startViewTransition(() => actualizarVista());

React Router 7 (<Link viewTransition>), Astro y Vue tienen soporte integrado. Es mejora progresiva: donde no hay soporte, simplemente no hay animación.


M.6 · Testear accesibilidad (automático + manual)

Lo automático caza ~40% (contraste, labels, ARIA inválido); el resto es probar.

typescript
// 1. Playwright + axe-core en e2e: falla el CI si hay violaciones
import AxeBuilder from '@axe-core/playwright';

test('la carta no tiene violaciones a11y', async ({ page }) => {
  await page.goto('/carta');
  const { violations } = await new AxeBuilder({ page }).analyze();
  expect(violations).toEqual([]);
});
tsx
// 2. Testing Library YA te empuja a lo accesible: si esto no encuentra el botón,
//    un lector de pantalla tampoco lo encontrará
screen.getByRole('button', { name: /añadir al carrito/i });

Checklist manual (15 min por vista):

□ Recorre todo con Tab: orden lógico, focus visible, sin trampas.
□ Esc cierra modales; el focus vuelve a donde estaba.
□ Zoom 200%: nada se corta ni se solapa.
□ Lighthouse a11y ≥ 95 y axe DevTools sin errores.
□ 5 minutos con un lector real: VoiceOver (Cmd+F5, macOS), NVDA (Windows, gratis)
  u Orca (Linux). La primera vez es humillante y es la mejor clase de a11y que existe.

M.7 · Buenas prácticas

  1. HTML semántico primero, ARIA último. El elemento nativo siempre gana.
  2. Focus visible y gestionado — modales, rutas, toasts. El focus es el cursor del teclado.
  3. Todo input con label; todo botón-icono con nombre; toda imagen con alt (o alt vacío).
  4. Contraste AA medido, no estimado a ojo.
  5. prefers-reduced-motion en todo proyecto con animaciones. Sin excepciones.
  6. axe en CI + prueba de teclado manual en cada feature nueva.
  7. La accesibilidad se diseña, no se parchea: entra en el checkpoint 4 del proyecto final, no en el 9.

✅ Ejercicio del apéndice

1. Audita tu tienda (caps. 17-19) con axe DevTools y Lighthouse: lista las violaciones.
2. Arregla el flujo de compra completo para superar la prueba del ratón desenchufado
   (Tab, Enter, Esc), incluyendo el modal de confirmación con <dialog>.
3. Conecta los errores de formulario con aria-describedby + role="alert" y anuncia
   los resultados de búsqueda con una live region.
4. Corrige todos los contrastes por debajo de 4.5:1 (ajusta tus variables CSS del cap. 15).
5. Añade view transitions al ir de la carta al detalle (imagen compartida) y
   la media query de reduced motion.
6. Mete AxeBuilder en tus e2e de Playwright y déjalo fallando el CI si hay violaciones.
7. Navega tu app 10 minutos con VoiceOver/NVDA y anota 3 cosas que te sorprendieron.
💡 Pistas
  • Si getByRole('button') no encuentra tu "botón", ya sabes qué elemento es en realidad.
  • El modal: <dialog> + dialog.showModal() te da trampa de focus y Esc gratis; solo te queda devolver el focus al disparador en el close.
  • Violación típica del punto 6 en apps reales: botones de icono sin nombre y contraste del texto secundario. Empieza por ahí.

🧠 Autoevaluación

El div no recibe focus con Tab, no se activa con Enter/Espacio y el lector de pantalla no lo anuncia como interactivo. <button> trae focus, teclado, rol y estados nativos sin una línea extra.

Cuando el patrón no existe en HTML nativo: tabs, combobox autocompletado, live regions, aria-expanded en desplegables. ARIA describe semántica; jamás añade comportamiento — el teclado sigue siendo tu responsabilidad.

El anuncio de nueva página y la posición del focus. Se reponen moviendo el focus al <h1> (tabindex="-1") de la vista nueva y/o anunciando el cambio en una live region.

Cambiar width fuerza recálculo de layout y repintado de todo lo afectado en el hilo principal; transform/opacity se componen en la GPU sin re-layout. En móviles baratos la diferencia es 60 fps vs 15.

Seguir el patrón combobox del ARIA APG (role="combobox", aria-expanded, aria-controls, aria-activedescendant sobre una lista role="listbox"/option) o usar una librería que ya lo implemente (React Aria, Radix UI). Un <input> con sugerencias en <div> y solo onClick funciona con ratón pero es invisible por teclado y para lectores de pantalla.


Volver al: README.md · Relacionado: 15-fundamentos-frontend.md, 17-react.md, 09-testing.md