En 04-01 vimos que los controles nativos de formulario (<input>, <select>, <textarea>, <button>) traen rol, foco y operación por teclado de fábrica. Pero un formulario accesible necesita algo más que buenos controles: necesita que cada control tenga nombre, que los campos relacionados estén agrupados, que las ayudas y los errores lleguen a todo el mundo y que el orden de foco sea lógico. Esos son precisamente los criterios 3.3.x ("Ayuda a la entrada de datos") que en el Módulo 3 estudiamos como requisito y que ahora implementamos.

El terreno de práctica es el formulario de matrícula/registro de Cursalia: nombre, correo, teléfono, contraseña, nivel de estudios, intereses y aceptación de condiciones. Lo iremos construyendo pieza a pieza, siempre con ejemplos correctos frente a incorrectos, pensando en Marta (NVDA debe leerle la etiqueta de cada campo), Hugo (necesita instrucciones claras y sin ambigüedad) y Lucía (lo recorre entero con el teclado).

Contenido

  1. La etiqueta: asociar label y control
  2. Por qué el placeholder no es una etiqueta
  3. Agrupar con fieldset y legend
  4. Campos obligatorios: required y aria-required
  5. Instrucciones y ayudas con aria-describedby
  6. Errores accesibles (y un primer vistazo a role="alert")
  7. autocomplete y tipos de input
  8. Foco y orden lógico
  9. Errores comunes y consejos
  10. Ejercicios
  11. Conclusión

La etiqueta: asociar label y control

Todo control de formulario debe tener una etiqueta accesible. La etiqueta es el name del control (recuerda 4.1.2): sin ella, Marta oye "cuadro de edición" sin saber qué escribir. Hay dos formas correctas de asociar un <label> con su control.

Opción A — for / id (la más común y flexible): el atributo for del label apunta al id del control.

<!-- CORRECTO: for apunta al id -->
<label for="email">Correo electrónico</label>
<input type="email" id="email" name="email">

Opción B — label envolvente: el control va dentro del <label>. No hace falta for/id.

<!-- CORRECTO: label envuelve al control -->
<label>
  Correo electrónico
  <input type="email" name="email">
</label>

Ambas hacen que al pulsar sobre el texto se enfoque el campo (área de clic mayor, algo que agradece Lucía) y que el lector lo anuncie con su nombre. El error habitual es no asociarlos: texto suelto al lado de un input.

<!-- INCORRECTO: el texto no está asociado al campo -->
<span>Correo electrónico</span>
<input type="email" name="email">

Aquí no hay <label> (o su for no coincide con ningún id): Marta oye "cuadro de edición en blanco". Si por diseño no puedes mostrar un texto visible (por ejemplo, el buscador del catálogo con solo una lupa), usa aria-label, que veremos en 04-03:

<input type="search" aria-label="Buscar cursos">

Por qué el placeholder no es una etiqueta

Es tentador poner solo un placeholder="Correo electrónico" y ahorrarse el <label>. Es un error serio por varios motivos:

Problema del placeholder como etiqueta A quién afecta
Desaparece al empezar a escribir; ya no sabes qué era ese campo Hugo (memoria/atención), todos
Su contraste suele ser bajo (gris claro) y no cumple 1.4.3 Sofía (baja visión)
Muchos lectores no lo anuncian como nombre fiable Marta
No se puede pulsar para enfocar el campo Lucía
<!-- INCORRECTO: solo placeholder, sin label -->
<input type="email" placeholder="Correo electrónico">

<!-- CORRECTO: label visible; el placeholder, si acaso, solo como ejemplo de formato -->
<label for="email">Correo electrónico</label>
<input type="email" id="email" placeholder="[email protected]">

Regla: el placeholder es, como mucho, una pista de formato; nunca sustituye a la etiqueta.

Agrupar con fieldset y legend

Cuando varios controles forman un solo grupo conceptual —un conjunto de radios (elige uno) o de checkboxes (elige varios)—, hay que envolverlos en <fieldset> y darles un título común con <legend>. Así el lector anuncia el grupo ("Nivel de estudios, grupo") antes de cada opción, dando el contexto que un radio suelto no tendría.

<!-- CORRECTO: grupo de radios con fieldset/legend -->
<fieldset>
  <legend>Nivel de estudios</legend>

  <label><input type="radio" name="nivel" value="secundaria"> Secundaria</label>
  <label><input type="radio" name="nivel" value="grado"> Grado universitario</label>
  <label><input type="radio" name="nivel" value="master"> Máster o superior</label>
</fieldset>

Marta oye: "Nivel de estudios, grupo. Secundaria, botón de radio, 1 de 3". Sin el fieldset, oiría tres radios sueltos sin saber a qué pregunta responden.

<!-- INCORRECTO: un texto suelto no agrupa nada semánticamente -->
<p>Nivel de estudios</p>
<label><input type="radio" name="nivel" value="grado"> Grado</label>

Lo mismo aplica a los intereses (checkboxes) o a la aceptación de varias condiciones. No abuses del fieldset para campos independientes (nombre, correo): es solo para grupos.

Campos obligatorios: required y aria-required

Marcar un campo como obligatorio tiene dos caras que hay que cubrir a la vez: la funcional y la comunicativa.

  • required: atributo nativo. Activa la validación del navegador y expone el estado "requerido" al lector (Marta oye "requerido"). Es la primera opción.
  • aria-required="true": solo si necesitas comunicar la obligatoriedad sin activar la validación nativa del navegador. No dupliques ambos sin motivo; con required normalmente basta.
  • Indicación visual y textual: no basta con un asterisco rojo (falla 1.4.1, "no solo por color/forma"). Explica qué significa el asterisco y, mejor aún, refuérzalo con texto.
<!-- CORRECTO: obligatorio comunicado de tres formas: nativa, visual y textual -->
<p id="leyenda-oblig">Los campos marcados con * son obligatorios.</p>

<label for="nombre">Nombre completo *</label>
<input type="text" id="nombre" name="nombre" required
       aria-describedby="leyenda-oblig">
<!-- INCORRECTO: la obligatoriedad vive solo en el color del asterisco -->
<label for="nombre">Nombre completo <span style="color:red">*</span></label>
<input type="text" id="nombre">   <!-- ni required, ni explicación del * -->

Instrucciones y ayudas con aria-describedby

Muchos campos necesitan una ayuda: formato de la contraseña, para qué se usa el teléfono, etc. Para que esa ayuda sea leída junto al campo (y no quede como texto huérfano al lado), se asocia con aria-describedby, que apunta al id del texto de ayuda. El lector lee primero la etiqueta y después la descripción.

<!-- CORRECTO: la ayuda se asocia con aria-describedby -->
<label for="pass">Contraseña</label>
<input type="password" id="pass" name="pass"
       aria-describedby="ayuda-pass" required>
<p id="ayuda-pass">Mínimo 8 caracteres, con una letra y un número.</p>

Marta oye: "Contraseña, cuadro de edición, requerido. Mínimo 8 caracteres, con una letra y un número". Hugo lee esa misma instrucción clara antes de escribir, no después de fallar. aria-describedby puede apuntar a varios ids separados por espacios (por ejemplo, la ayuda y el mensaje de error del mismo campo).

<!-- INCORRECTO: la ayuda existe visualmente pero no está asociada -->
<label for="pass">Contraseña</label>
<input type="password" id="pass">
<p>Mínimo 8 caracteres, con una letra y un número.</p>  <!-- sin aria-describedby: el lector no la vincula -->

Errores accesibles (y un primer vistazo a role="alert")

Cuando el usuario se equivoca, el criterio 3.3.1 exige identificar el error en texto (no solo con color) y describirlo de forma útil. Buenas prácticas:

  • Texto explícito, no solo un borde rojo: "El correo no es válido: falta la @".
  • Asociado al campo con aria-describedby, para que el lector lo lea al enfocar ese campo.
  • No depender del color: añade un icono o la palabra "Error" (esto es 1.4.1, que veremos a fondo en 04-04).
  • Mover el foco al primer campo con error o resumir los errores al principio ayuda a Lucía y a Marta.
<!-- CORRECTO: error en texto, asociado, y no solo por color -->
<label for="email">Correo electrónico *</label>
<input type="email" id="email" name="email" required
       aria-describedby="err-email" aria-invalid="true">
<p id="err-email" class="error">
  <span aria-hidden="true">⚠️</span> Error: el correo debe incluir una @.
</p>

Aquí aria-invalid="true" marca el campo como inválido en el árbol de accesibilidad, y aria-describedby hace que el mensaje se lea al enfocarlo.

Cuando el error aparece de forma dinámica (tras pulsar "Enviar", sin recargar la página), hace falta además anunciarlo aunque el foco esté en otro sitio. Para eso existen las live regions. Una introducción breve: un contenedor con role="alert" (o aria-live="assertive") hace que el lector lea automáticamente el texto que aparezca dentro, sin que el usuario tenga que ir a buscarlo.

<!-- El lector anunciará el texto en cuanto tu JS lo inserte aquí -->
<div role="alert" id="resumen-errores"></div>
// Al validar, insertas el mensaje y NVDA lo lee solo:
document.getElementById('resumen-errores').textContent =
  'Revisa el formulario: hay 2 campos con errores.';

Nos quedamos en esta introducción: el detalle de aria-live, role="status" vs role="alert" y las regiones dinámicas se ve a nivel de marcado en 04-03, y la validación completa con JavaScript (cuándo validar, cómo mover el foco, actualizar mensajes en vivo) pertenece al Módulo 5.

autocomplete y tipos de input

Elegir bien el type y añadir autocomplete reduce el esfuerzo de todos, y de forma muy notable para Lucía (menos tecleo), Hugo (menos que recordar) y quien usa control por voz o móvil.

Campo type correcto autocomplete Beneficio
Correo email email Teclado con @, validación de formato, autorrelleno
Teléfono tel tel Teclado numérico en móvil
Nombre text name Autorrelleno del nombre guardado
Contraseña (registro) password new-password El gestor sugiere una segura
<!-- CORRECTO: tipos y autocomplete que ayudan al navegador y a la persona -->
<label for="email">Correo electrónico</label>
<input type="email" id="email" name="email" autocomplete="email">

<label for="tel">Teléfono</label>
<input type="tel" id="tel" name="tel" autocomplete="tel">

autocomplete cumple además el criterio 1.3.5 ("Identificar el propósito de la entrada"), que ayuda a herramientas que adaptan o rellenan campos por el usuario.

Foco y orden lógico

El orden en que Lucía llega a los campos al pulsar Tab debe seguir el orden visual y lógico del formulario. La clave es que el orden del DOM coincida con el orden visual: si maquetas de arriba abajo en el HTML, el foco irá bien sin trucos.

  • No uses tabindex positivo (tabindex="1", "2"...): rompe el orden natural y es una fuente constante de bugs. Deja que el orden lo dicte el DOM.
  • Un <button type="submit"> real permite enviar el formulario con Enter desde cualquier campo.
  • Tras enviar con errores, llevar el foco al resumen de errores o al primer campo fallido ahorra a Lucía tabular a ciegas (implementación con JS: Módulo 5).
<!-- INCORRECTO: tabindex positivo fuerza un orden artificial y frágil -->
<input id="a" tabindex="3">
<input id="b" tabindex="1">
<input id="c" tabindex="2">
<!-- CORRECTO: sin tabindex; el orden del DOM ya es el orden de foco -->
<input id="a">
<input id="b">
<input id="c">

Errores Comunes y Consejos

  • Campos sin <label> asociado. Es el fallo número uno. Cada control necesita nombre por for/id, label envolvente o, en último caso, aria-label.
  • Usar el placeholder como etiqueta. Desaparece, contrasta mal y no es fiable como nombre. Pon siempre etiqueta visible.
  • Obligatoriedad solo por color. El asterisco rojo no basta: añade required y explica qué significa el *.
  • Ayudas y errores sin asociar. Un texto de ayuda al lado del campo que no está enlazado con aria-describedby es invisible para el lector.
  • Errores solo con borde rojo. Marta no ve el color: el error debe ir en texto y estar asociado.
  • Radios/checkboxes sin fieldset/legend. Pierden el contexto de la pregunta.
  • tabindex positivo. Casi nunca es la solución; ordena el DOM.
  • Consejo: rellena tu formulario entero solo con el teclado y con un lector, con los ojos cerrados. Si no sabes qué pide un campo o dónde está el error, aún no es accesible.

Ejercicios

Ejercicio 1: dar nombre a los campos

Este fragmento del registro de Cursalia no asocia etiquetas. Corrígelo usando for/id y sustituye el placeholder-etiqueta por una etiqueta real.

<span>Nombre</span>
<input type="text">
<input type="email" placeholder="Correo electrónico">

Ejercicio 2: agrupar los intereses

Los usuarios pueden marcar varios intereses. Marca correctamente este grupo de checkboxes para que el lector anuncie el título del grupo.

<p>Intereses</p>
<label><input type="checkbox" name="int" value="dev"> Desarrollo</label>
<label><input type="checkbox" name="int" value="ux"> Diseño UX</label>
<label><input type="checkbox" name="int" value="data"> Datos</label>

Ejercicio 3: un campo con ayuda y error accesibles

Escribe el campo "Contraseña": etiqueta visible, obligatorio, con una ayuda de formato asociada y un mensaje de error (asociado y no dependiente solo del color) para cuando la contraseña sea demasiado corta.

Soluciones

Solución 1. Cada control recibe una etiqueta asociada; el placeholder deja paso a un <label> real:

<label for="nombre">Nombre</label>
<input type="text" id="nombre" name="nombre" autocomplete="name">

<label for="email">Correo electrónico</label>
<input type="email" id="email" name="email" autocomplete="email">

Solución 2. El grupo entero va en un <fieldset> con <legend>:

<fieldset>
  <legend>Intereses</legend>
  <label><input type="checkbox" name="int" value="dev"> Desarrollo</label>
  <label><input type="checkbox" name="int" value="ux"> Diseño UX</label>
  <label><input type="checkbox" name="int" value="data"> Datos</label>
</fieldset>

Ahora el lector anuncia "Intereses, grupo" antes de cada casilla.

Solución 3. Etiqueta visible + required + ayuda con aria-describedby + error en texto asociado, con icono además del color:

<label for="pass">Contraseña *</label>
<input type="password" id="pass" name="pass" required
       autocomplete="new-password"
       aria-describedby="ayuda-pass err-pass"
       aria-invalid="true">

<p id="ayuda-pass">Mínimo 8 caracteres, con una letra y un número.</p>
<p id="err-pass" class="error">
  <span aria-hidden="true">⚠️</span> Error: la contraseña es demasiado corta.
</p>

Fíjate en que aria-describedby apunta a dos ids (ayuda y error), así el lector lee ambos. Cuando la contraseña sea válida, tu código quitará aria-invalid y el mensaje de error (eso ya es lógica de JS, Módulo 5).

Conclusión

Un formulario accesible no es magia: es etiquetar cada control, agrupar lo que va junto, comunicar la obligatoriedad y las ayudas por texto asociado, describir los errores de forma clara y no solo por color, y respetar el orden de foco del DOM. Con esto, el formulario de matrícula de Cursalia deja de ser un muro para Marta, Hugo y Lucía. Hemos apoyado casi todo en HTML nativo y solo hemos asomado la cabeza a aria-describedby, aria-invalid y role="alert".

Esos atributos aria-* son la puerta a la siguiente lección. En 04-03 · Uso de Roles y Propiedades ARIA daremos un paso atrás para entender qué es ARIA, sus cinco reglas de oro (empezando por "no lo uses si hay HTML nativo"), cómo se calcula el nombre accesible que hemos ido usando (aria-label, aria-labelledby, aria-describedby) y cómo describir estados como aria-expanded en el desplegable de filtros de Cursalia, siempre a nivel de marcado.

© Copyright 2026. Todos los derechos reservados