learning-front

Nivel 1 · HTML y CSS: la estructura y la piel

Accesibilidad: WCAG, contraste, aria y foco

Construir interfaces que cualquiera pueda usar, también con teclado y lector de pantalla: contraste AA, botones reales, foco visible y cuándo usar aria-*.

A estas alturas ya sabes construir y maquetar una página entera: HTML semántico que describe su contenido, CSS con selectores precisos, colores con contraste medido, y un layout que se adapta a cualquier pantalla. Lo que queda por comprobar es que cualquier persona pueda usarla. También quien navega sin ratón, o con un lector de pantalla. Eso es accesibilidad.

La accesibilidad se enseña como si fuera caridad. Como un extra para usuarios con discapacidad, opcional y postergable. Eso está mal en dos sentidos: técnico y ético.

Técnico: una interfaz inaccesible está rota. Si alguien no puede operar tu aplicación con teclado, o no puede leer un texto porque el contraste es insuficiente, eso no es una característica ausente. Es un error. Como lo sería un enlace que no lleva a ningún sitio.

Ético: en muchos países, y en España desde hace años, la accesibilidad es una obligación legal para el sector público y para servicios esenciales. Pero incluso donde no lo es, una interfaz que excluye usuarios tiene un coste real: humano y de negocio.

La buena noticia: la mayor parte de la accesibilidad no cuesta nada extra. Viene de usar el HTML como fue diseñado.

Qué es WCAG y qué pide#

Las Web Content Accessibility Guidelines (WCAG) son el estándar de referencia. Su versión actual es la 2.2. Organizan los requisitos en cuatro principios, que se enuncian en castellano llano:

  • Perceptible: la información tiene que poder percibirse. Un texto que no se lee por falta de contraste no es perceptible; una imagen sin alt no lo es para quien no la ve.
  • Operable: todos los controles deben poder accionarse. Si algo solo funciona con ratón, no es operable.
  • Comprensible: el comportamiento tiene que ser predecible y los errores, claros. Un formulario que falla sin decir por qué no es comprensible.
  • Robusto: el código tiene que funcionar con tecnología asistiva actual y futura. HTML semántico es la base de eso.

Cada principio se divide en criterios de éxito con tres niveles: A (mínimo), AA (estándar de facto en empresa) y AAA (excelencia, no siempre alcanzable en todo un sitio). El objetivo práctico en la mayoría de proyectos es cumplir AA.

Contraste de color#

En «Color, unidades y tipografía» ya viste cómo medir el ratio de contraste y por qué importa para la legibilidad. WCAG lo formaliza en el criterio 1.4.3 (nivel AA):

  • Texto normal: ratio mínimo 4.5:1 con el fondo.
  • Texto grande (18 pt / 14 pt negrita o más): ratio mínimo 3:1.
  • Componentes de interfaz (bordes de inputs, iconos con significado): 3:1.

Un ejemplo del Team Builder: el gris claro #a0a0b8 sobre fondo blanco da un ratio de ~2.8:1. Falla AA. El gris oscuro #5b5966 sobre el mismo fondo da ~5.9:1. Pasa con margen. El cambio de color es el único ajuste necesario.

Cuándo el HTML semántico ya es suficiente#

Antes de hablar de aria-*, la regla más importante: no uses ARIA si una etiqueta nativa ya lo dice. Añadir role="button" a un <div> es menos robusto que usar <button> directamente. El navegador gestiona el estado del botón (foco, teclado, aria-pressed); si usas un div, lo gestionas tú.

El HTML semántico que ya conoces cubre la mayor parte:

  • <button>: tabulable, activable con Enter/Espacio, anunciado como “botón”.
  • <a href>: tabulable, anunciado como “enlace”, activado con Enter.
  • <input>, <label>, <select>: relación etiqueta-campo sin ARIA.
  • <header>, <nav>, <main>, <footer>: landmarks navegables.
  • <h1><h6>: índice de la página.
  • <ul>/<li>: lista de elementos, con recuento automático.

Cuándo sí hace falta aria-*#

Cuando el HTML no llega. Tres casos habituales:

aria-hidden="true": oculta un elemento a la tecnología asistiva. Útil para decoración que ya está descrita en texto (un icono junto a un texto, un retrato de iniciales junto al nombre del héroe). Sin él, el lector lo lee dos veces o anuncia algo sin sentido.

aria-label: da un nombre accesible cuando el nombre visual no es suficiente en contexto. Si tienes tres botones que dicen “Eliminar”, el lector de pantalla anuncia tres veces la misma acción. aria-label="Eliminar a Tracer" resuelve la ambigüedad. La regla: el nombre accesible de un control debe describir qué hace y sobre qué.

aria-labelledby: lo mismo que aria-label, pero el nombre se toma de otro elemento de la página (por su id). Útil para atar una región a su título: <section aria-labelledby="titulo-plantilla"> anunciará la región con el texto del encabezado correspondiente.

Hay muchos más atributos ARIA, pero estos tres son los que se usan el 80% de las veces. El resto aparece cuando construyes componentes complejos que el HTML nativo no cubre: tabpanels, acordeones, menús. Los abordaremos cuando lleguemos a componentes interactivos en niveles posteriores.

Quien no usa ratón navega con Tab entre los elementos focalizables (enlaces, botones, inputs). Lo que se conoce como foco visible es el indicador que muestra dónde está el cursor en cada momento.

El error más extendido es quitarlo sin reemplazarlo:

css
/* No hagas esto */
button:focus {
  outline: none;
}

Recuerda que :focus es la pseudo-clase que viste en «Selectores CSS»: se activa cuando un elemento recibe el foco, tanto con teclado como con clic de ratón. El efecto de quitarle el outline sin alternativa es que quien navega con teclado no sabe dónde está. No hay metáfora: literalmente no ve el cursor.

La solución es :focus-visible, una variante más inteligente que solo muestra el indicador cuando la navegación es por teclado. Así puedes quitar el outline en clics de ratón (donde no hace falta) sin penalizar a quien tabula:

css
/* Bien: quita el outline en clics, lo muestra al tabular */
button:focus {
  outline: none;
}
button:focus-visible {
  outline: 2px solid var(--acento);
  outline-offset: 3px;
}

El estilo del foco no tiene por qué ser el outline por defecto del navegador. Puede ser un box-shadow, un borde de color, un fondo: lo que encaje con el diseño. Lo que no puede hacer es desaparecer.

Animaciones y prefers-reduced-motion#

Algunos usuarios configuran su sistema operativo para pedir que las animaciones sean mínimas —porque les causan mareo, desorientación o simplemente molestia—. CSS tiene una media query que lo detecta y la respetas con un par de líneas:

css
@media (prefers-reduced-motion: reduce) {
  /* Cuando el usuario ha pedido menos movimiento, quitamos transiciones y animaciones */
  *, *::before, *::after {
    /* cancela las animaciones sin eliminarlas del código */
    animation-duration: 0.01ms !important;
    /* cancela las transiciones */
    transition-duration: 0.01ms !important;
  }
}

No hay que reescribir nada: este bloque se pone encima de lo existente y anula el movimiento para quien lo necesita. Es un criterio WCAG (2.3.3, nivel AAA) y cuesta menos de diez segundos añadirlo.

alt con criterio#

El atributo alt en imágenes no es un campo de descripción automática: es contexto editorial. Qué pones depende de lo que la imagen aporta:

  • Imagen con contenido propio (un gráfico de estadísticas, una foto que muestra algo): describe lo que informa. alt="Gráfico de winrate por rol: Apoyo 63%, Tanque 58%, Daño 52%".
  • Imagen decorativa cuyo contenido ya está en texto (el retrato de Tracer junto al texto “Tracer”): alt="". El lector la ignora en lugar de repetir información.
  • Imagen sin alt: el lector anuncia el nombre del fichero. Casi siempre es lo peor que puede pasar.

<button> frente a <div> clicable#

Es el error más común y el más fácil de evitar. Un <div onclick>:

  • No recibe foco con Tab de serie.
  • No se activa con Enter ni con Espacio.
  • Un lector de pantalla lo lee como texto, no como control.

Para que un div haga lo que un botón hace, necesitas añadir tabindex="0", gestionar el evento keydown para Enter y Espacio, y añadir role="button". Eso es replicar a mano lo que el navegador ya hace gratis con <button>.

La regla es simple: si algo se puede pulsar, usa <button>. Si lleva a otro sitio, usa <a href>. Para todo lo demás, hay etiquetas nativas.

Cómo medirlo, no adivinarlo#

No tienes que juzgar la accesibilidad a ojo. El WebAIM Contrast Checker te da el ratio de contraste de dos colores al instante. Y dentro del propio navegador, Lighthouse (en las DevTools, pestaña del mismo nombre) y la extensión axe DevTools revisan una página entera y listan los problemas con su criterio WCAG. Pásalas antes de dar una interfaz por buena: encuentran en segundos lo que a ojo se escapa.

Pruébalo#

Tres grupos de botones del Team Builder. El primero tiene el outline quitado; el segundo usa :focus-visible con estilo de marca; el tercero son <div> clicables. Pulsa Tab en el preview y observa la diferencia.

Comprueba lo que sabes#

Pregunta 1 de 5

Un diseñador pone un texto de ayuda en gris claro (#aaaaaa) sobre fondo blanco. El ratio de contraste es 2.3:1. ¿Qué dices en la revisión?

Tu turno#

El starter tiene un grid con problemas deliberados de accesibilidad. Encuéntralos y corrígelos directamente en el playground: edita el HTML para usar las clases correctas, sustituir los <div onclick> por controles reales y añadir los atributos ARIA que faltan. El CSS ya tiene todo lo que necesitas; no hace falta tocarlo. Cuando creas haberlo resuelto, despliega las soluciones y recorre cada una con Tab para ver la diferencia real.

Ejercicio · en esta página

Arregla la accesibilidad del Team Builder

El grid de héroes del starter tiene varios problemas de accesibilidad deliberados: tarjetas con `<div onclick>` en lugar de controles reales, contraste de color bajo, retratos sin `aria-hidden` e imagen sin `alt`. Corrígelos. El CSS ya tiene las clases y estilos que necesitas; tu trabajo es aplicarlos.

Paso 1: Funciona con el ratón

  • La página se ve bien y el clic funciona.
  • Los problemas de accesibilidad siguen intactos: `<div onclick>`, contraste bajo, sin `aria-hidden`, sin `alt`.
  • Nadie que use teclado o lector de pantalla puede operar la página.
Ver soluciones
<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Overwatch Team Builder — Accesibilidad</title>
    <link rel="stylesheet" href="../../starter/styles.css" />
  </head>
  <body>
    <!--
      ============================================================================
      NIVEL OK — "funciona con el ratón"
      ============================================================================
      Esta versión se ve bien y el clic funciona. Eso es todo lo que hace bien.

      Sus problemas (los que arregla el nivel Mejor):
        - Las tarjetas son <div onclick>: no son tabulables con el teclado.
          Alguien que no usa ratón no puede llegar a ellas. La tecnología asistiva
          no las anuncia como interactivas: las lee como bloques de texto.
        - El color del rol usa --rol-color-bajo (~2.8:1 de contraste). No supera
          el mínimo AA de WCAG (4.5:1 para texto normal). En condiciones de luz
          brillante o con baja visión es prácticamente ilegible.
        - Los retratos de Tracer y Reinhardt no tienen aria-hidden: el lector
          leerá "TR, Tracer, Daño..." anunciando las iniciales como si fueran
          contenido con significado.
        - La imagen de Mercy no tiene alt: el lector anuncia la ruta del fichero,
          que no dice nada útil.

      No es que esto esté "mal" en un sentido absoluto. A veces heredas código así.
      El punto es saber identificar estos problemas antes de que lleguen a producción.
    -->

    <div class="page">
      <header class="site-header">
        <h1 class="brand">Overwatch <span>Team Builder</span></h1>
        <nav class="site-nav" aria-label="Principal">
          <a href="#">Héroes</a>
          <a href="#">Equipos</a>
          <a href="#">Estadísticas</a>
        </nav>
      </header>

      <main id="contenido">
        <section aria-labelledby="titulo-plantilla">
          <h2 class="section-title" id="titulo-plantilla">Plantilla</h2>

          <ul class="hero-grid">

            <li>
              <div class="hero-card hero-card-clickable" onclick="alert('Tracer')">
                <div class="hero-top">
                  <div class="hero-portrait">TR</div>
                  <div>
                    <h3 class="hero-name">Tracer</h3>
                    <p class="hero-role">Daño</p>
                  </div>
                </div>
                <dl class="hero-stats">
                  <div class="stat"><dt>Partidas</dt><dd>120</dd></div>
                  <div class="stat"><dt>Victorias</dt><dd>78</dd></div>
                  <div class="stat"><dt>Winrate</dt><dd>65%</dd></div>
                </dl>
              </div>
            </li>

            <li>
              <div class="hero-card hero-card-clickable" onclick="alert('Reinhardt')">
                <div class="hero-top">
                  <div class="hero-portrait">RE</div>
                  <div>
                    <h3 class="hero-name">Reinhardt</h3>
                    <p class="hero-role">Tanque</p>
                  </div>
                </div>
                <dl class="hero-stats">
                  <div class="stat"><dt>Partidas</dt><dd>90</dd></div>
                  <div class="stat"><dt>Victorias</dt><dd>51</dd></div>
                  <div class="stat"><dt>Winrate</dt><dd>57%</dd></div>
                </dl>
              </div>
            </li>

            <li>
              <div class="hero-card hero-card-clickable" onclick="alert('Mercy')">
                <div class="hero-top">
                  <img src="mercy-portrait.png" class="hero-portrait" width="48" height="48" />
                  <div>
                    <h3 class="hero-name">Mercy</h3>
                    <p class="hero-role">Apoyo</p>
                  </div>
                </div>
                <dl class="hero-stats">
                  <div class="stat"><dt>Partidas</dt><dd>200</dd></div>
                  <div class="stat"><dt>Victorias</dt><dd>130</dd></div>
                  <div class="stat"><dt>Winrate</dt><dd>65%</dd></div>
                </dl>
              </div>
            </li>

          </ul>
        </section>
      </main>

      <footer class="site-footer">Overwatch Team Builder — proyecto del curso.</footer>
    </div>
  </body>
</html>

Por qué este nivel

  • Se ve bien y el clic funciona. Desde el ratón no hay nada que reprochar.
  • Sus límites: las tarjetas son `<div onclick>`, no tabulables. El teclado las ignora; el lector las lee como bloques de texto, no como controles.
  • El color del rol usa `--rol-color-bajo` (~2.8:1), por debajo del mínimo AA de 4.5:1. En luz directa o con baja visión es casi ilegible.
  • Los retratos anuncian "TR", "RE", "ME" al lector antes del nombre. La imagen de Mercy anuncia su ruta de fichero.