Ver contenido
Introducción
Las etiquetas de formulario son descripciones de texto que identifican el propósito de los campos de formulario. Son esenciales para la accesibilidad porque los lectores de pantalla dependen de etiquetas correctamente asociadas para anunciar qué información espera cada campo. WCAG 2.2 incluye múltiples criterios de éxito relacionados con etiquetas, haciéndolas un requisito crítico de accesibilidad.
Sin etiquetas apropiadas, los usuarios que no pueden ver el diseño visual de tu formulario no sabrán qué información ingresar en cada campo—haciendo tus formularios esencialmente inutilizables para ellos.
¿Qué Son las Etiquetas de Formulario?
El Elemento HTML Label
Las etiquetas se conectan a los inputs de formulario usando el elemento <label>:
<!-- Método 1: Usando asociación for/id -->
<label for="email">Correo Electrónico</label>
<input type="email" id="email" name="email">
<!-- Método 2: Envolviendo el input -->
<label>
Correo Electrónico
<input type="email" name="email">
</label>
Tipos de Etiquetado de Formulario
| Método | Caso de Uso | Ejemplo |
|---|---|---|
Elemento <label> |
Campos de formulario estándar | Inputs de texto, selects, checkboxes |
aria-label |
Etiquetas visualmente ocultas | Inputs de búsqueda, botones de ícono |
aria-labelledby |
Etiqueta desde otros elementos | Grupos de formularios complejos |
Atributo title |
Último recurso solamente | Evitar cuando sea posible |
| Placeholder | No es una etiqueta | No debe reemplazar etiquetas |
Por Qué Importan las Etiquetas
Quién Se Beneficia
- Usuarios de lectores de pantalla - Las etiquetas se anuncian al enfocar campos
- Usuarios con discapacidades motoras - Click en etiqueta activa el campo
- Usuarios con discapacidades cognitivas - Etiquetas claras reducen confusión
- Todos - Áreas de click más grandes mejoran usabilidad
El Beneficio del Área de Click
Cuando las etiquetas están correctamente asociadas, hacer click en la etiqueta enfoca el input:
<!-- Click en "Acepto los términos" marca el checkbox -->
<input type="checkbox" id="terms">
<label for="terms">Acepto los términos y condiciones</label>
Esto aumenta significativamente el área clickeable—especialmente útil para checkboxes y radio buttons pequeños.
Requisitos WCAG
| Criterio | Nivel | Requisito |
|---|---|---|
| 1.3.1 Información y Relaciones | A | Las etiquetas deben estar asociadas programáticamente |
| 3.3.2 Etiquetas o Instrucciones | A | Los campos de entrada deben tener etiquetas o instrucciones |
| 2.4.6 Encabezados y Etiquetas | AA | Las etiquetas deben ser descriptivas |
Problemas Comunes de Etiquetado
1. Etiquetas Faltantes
<!-- MAL: Sin etiqueta en absoluto -->
<input type="text" name="username">
<!-- BIEN: Asociación de etiqueta apropiada -->
<label for="username">Nombre de Usuario</label>
<input type="text" id="username" name="username">
2. Placeholder como Etiqueta
<!-- MAL: Placeholder no es una etiqueta -->
<input type="email" placeholder="Ingresa tu correo">
<!-- BIEN: Etiqueta con placeholder opcional -->
<label for="email">Correo Electrónico</label>
<input type="email" id="email" placeholder="[email protected]">
Por qué los placeholders no son etiquetas:
- Desaparecen cuando el usuario empieza a escribir
- A menudo tienen pobre contraste
- No son anunciados por todos los lectores de pantalla
- Los usuarios pueden olvidar para qué es el campo
3. Etiquetas No Asociadas
<!-- MAL: La etiqueta existe pero no está conectada -->
<label>Nombre Completo</label>
<input type="text" id="nombre">
<!-- BIEN: for/id crea la asociación -->
<label for="nombre">Nombre Completo</label>
<input type="text" id="nombre">
4. IDs Duplicados
<!-- MAL: Ambos inputs tienen el mismo ID -->
<label for="email">Email de Trabajo</label>
<input type="email" id="email">
<label for="email">Email Personal</label>
<input type="email" id="email">
<!-- BIEN: IDs únicos para cada uno -->
<label for="email-trabajo">Email de Trabajo</label>
<input type="email" id="email-trabajo">
<label for="email-personal">Email Personal</label>
<input type="email" id="email-personal">
5. Etiquetas Ocultas Incorrectamente
<!-- MAL: display:none oculta de lectores de pantalla también -->
<label for="buscar" style="display: none;">Buscar</label>
<input type="search" id="buscar">
<!-- BIEN: Visualmente oculto pero accesible -->
<label for="buscar" class="sr-only">Buscar</label>
<input type="search" id="buscar">
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
Probando Etiquetas de Formulario
Prueba Manual Rápida
- Haz click en cada etiqueta—¿enfoca el input correcto?
- Navega con Tab por el formulario—¿el lector de pantalla anuncia el propósito de cada campo?
- ¿Cada campo requerido está claramente marcado?
Usando Herramientas de Desarrollo
Chrome DevTools:
1. Inspeccionar un input de formulario
2. Revisar el panel de Accesibilidad
3. Buscar propiedad "Name"
4. Verificar que coincide con la etiqueta visible
Herramientas Automatizadas
- axe DevTools - Detecta etiquetas faltantes e incorrectamente asociadas
- WAVE - Resalta campos de formulario sin etiquetas
- Lighthouse - Reporta problemas de accesibilidad de etiquetas
Correcciones Rápidas
Agregando Etiquetas a Formularios Existentes
<!-- Antes: Sin etiquetas -->
<input type="text" placeholder="Nombre">
<input type="email" placeholder="Correo">
<button>Enviar</button>
<!-- Después: Etiquetas apropiadas -->
<div>
<label for="nombre">Nombre</label>
<input type="text" id="nombre" placeholder="Juan Pérez">
</div>
<div>
<label for="correo">Correo Electrónico</label>
<input type="email" id="correo" placeholder="[email protected]">
</div>
<button type="submit">Enviar</button>
Etiquetando Inputs Solo con Ícono
<!-- Búsqueda con botón de ícono -->
<label for="buscar" class="sr-only">Buscar</label>
<input type="search" id="buscar">
<button type="submit" aria-label="Enviar búsqueda">
<svg aria-hidden="true"><!-- ícono de búsqueda --></svg>
</button>
Resumen de Mejores Prácticas
| Hacer | No Hacer |
|---|---|
Usar elemento <label> con for/id |
Usar placeholder como etiqueta |
| Hacer etiquetas descriptivas y concisas | Ocultar etiquetas con display:none |
| Usar IDs únicos para cada input | Duplicar IDs en una página |
| Marcar campos requeridos claramente | Confiar solo en asteriscos |
| Probar con lectores de pantalla | Asumir que proximidad visual es suficiente |
Implementación
Métodos de Etiquetado
1. El Elemento Label (Preferido)
El elemento <label> es el método principal para etiquetar controles de formulario:
<!-- Método 1: Asociación explícita con for/id -->
<label for="usuario">Nombre de Usuario</label>
<input type="text" id="usuario" name="usuario">
<!-- Método 2: Asociación implícita (envolviendo) -->
<label>
Nombre de Usuario
<input type="text" name="usuario">
</label>
Cuándo usar cada método:
| Método | Pros | Contras | Mejor Para |
|---|---|---|---|
| Explícito (for/id) | Diseño más flexible | Requiere IDs únicos | Mayoría de formularios |
| Implícito (envolver) | No necesita ID | Opciones de estilo limitadas | Formularios simples |
2. Etiquetado ARIA
Para casos donde <label> no es adecuado:
<!-- aria-label: Proporciona etiqueta invisible -->
<input type="search" aria-label="Buscar productos">
<!-- aria-labelledby: Referencia texto visible -->
<h2 id="encabezado-contacto">Información de Contacto</h2>
<input type="text" aria-labelledby="encabezado-contacto etiqueta-nombre">
<span id="etiqueta-nombre">Nombre Completo</span>
<!-- aria-describedby: Descripción adicional -->
<label for="password">Contraseña</label>
<input
type="password"
id="password"
aria-describedby="requisitos-password"
>
<p id="requisitos-password">
Debe tener al menos 8 caracteres con un número.
</p>
3. Prioridad de Etiquetado
Los lectores de pantalla usan este orden de prioridad para nombres accesibles:
1. aria-labelledby (mayor prioridad)
2. aria-label
3. elemento <label> (for/id o envolviendo)
4. atributo title (menor prioridad, evitar)
Patrones de Controles de Formulario
Inputs de Texto
<!-- Input de texto estándar -->
<div class="form-group">
<label for="nombre-completo">
Nombre Completo
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="nombre-completo"
name="nombre-completo"
required
aria-required="true"
autocomplete="name"
>
</div>
<!-- Input con texto de ayuda -->
<div class="form-group">
<label for="email">Correo Electrónico</label>
<input
type="email"
id="email"
name="email"
aria-describedby="email-ayuda"
autocomplete="email"
>
<p id="email-ayuda" class="help-text">
Nunca compartiremos tu correo con nadie.
</p>
</div>
<!-- Input con error -->
<div class="form-group has-error">
<label for="telefono">Número de Teléfono</label>
<input
type="tel"
id="telefono"
name="telefono"
aria-invalid="true"
aria-describedby="telefono-error"
>
<p id="telefono-error" class="error-text" role="alert">
Por favor ingresa un número de teléfono válido.
</p>
</div>
Selectores Desplegables
<!-- Select estándar -->
<div class="form-group">
<label for="pais">País</label>
<select id="pais" name="pais" autocomplete="country">
<option value="">-- Selecciona un país --</option>
<option value="mx">México</option>
<option value="es">España</option>
<option value="ar">Argentina</option>
</select>
</div>
<!-- Opciones agrupadas -->
<div class="form-group">
<label for="zona-horaria">Zona Horaria</label>
<select id="zona-horaria" name="zona-horaria">
<optgroup label="América">
<option value="cdmx">Ciudad de México</option>
<option value="bog">Bogotá</option>
</optgroup>
<optgroup label="Europa">
<option value="mad">Madrid</option>
<option value="lon">Londres</option>
</optgroup>
</select>
</div>
Checkboxes y Radio Buttons
<!-- Checkbox único -->
<div class="form-group">
<input type="checkbox" id="newsletter" name="newsletter">
<label for="newsletter">Suscribirme al boletín</label>
</div>
<!-- Grupo de checkboxes -->
<fieldset>
<legend>Preferencias de Notificación</legend>
<div class="checkbox-group">
<input type="checkbox" id="notif-email" name="notificaciones" value="email">
<label for="notif-email">Notificaciones por correo</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="notif-sms" name="notificaciones" value="sms">
<label for="notif-sms">Notificaciones por SMS</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="notif-push" name="notificaciones" value="push">
<label for="notif-push">Notificaciones push</label>
</div>
</fieldset>
<!-- Grupo de radio buttons -->
<fieldset>
<legend>Método de Envío</legend>
<div class="radio-group">
<input type="radio" id="estandar" name="envio" value="estandar">
<label for="estandar">Estándar (5-7 días) - Gratis</label>
</div>
<div class="radio-group">
<input type="radio" id="express" name="envio" value="express">
<label for="express">Express (2-3 días) - $9.99</label>
</div>
<div class="radio-group">
<input type="radio" id="urgente" name="envio" value="urgente">
<label for="urgente">Urgente - $24.99</label>
</div>
</fieldset>
Textareas
<div class="form-group">
<label for="mensaje">Mensaje</label>
<textarea
id="mensaje"
name="mensaje"
rows="5"
aria-describedby="mensaje-info"
></textarea>
<p id="mensaje-info" class="help-text">
Máximo 500 caracteres.
</p>
</div>
Patrones de Formularios Complejos
Campos Agrupados con Fieldset
<!-- Campos de dirección agrupados -->
<fieldset>
<legend>Dirección de Envío</legend>
<div class="form-group">
<label for="calle">Dirección</label>
<input type="text" id="calle" name="calle" autocomplete="street-address">
</div>
<div class="form-row">
<div class="form-group">
<label for="ciudad">Ciudad</label>
<input type="text" id="ciudad" name="ciudad" autocomplete="address-level2">
</div>
<div class="form-group">
<label for="estado">Estado/Provincia</label>
<select id="estado" name="estado" autocomplete="address-level1">
<option value="">Seleccionar...</option>
<!-- opciones de estado -->
</select>
</div>
<div class="form-group">
<label for="cp">Código Postal</label>
<input type="text" id="cp" name="cp" autocomplete="postal-code">
</div>
</div>
</fieldset>
Inputs de Fecha
<!-- Input de fecha único -->
<div class="form-group">
<label for="fecha-nacimiento">Fecha de Nacimiento</label>
<input
type="date"
id="fecha-nacimiento"
name="fecha-nacimiento"
autocomplete="bday"
>
</div>
<!-- Fecha multi-campo (navegadores antiguos) -->
<fieldset>
<legend>Fecha de Nacimiento</legend>
<div class="date-group">
<div class="form-group">
<label for="nac-mes">Mes</label>
<select id="nac-mes" name="nac-mes" autocomplete="bday-month">
<option value="">--</option>
<option value="1">Enero</option>
<!-- ... -->
</select>
</div>
<div class="form-group">
<label for="nac-dia">Día</label>
<input
type="number"
id="nac-dia"
name="nac-dia"
min="1"
max="31"
autocomplete="bday-day"
>
</div>
<div class="form-group">
<label for="nac-anio">Año</label>
<input
type="number"
id="nac-anio"
name="nac-anio"
min="1900"
max="2024"
autocomplete="bday-year"
>
</div>
</div>
</fieldset>
Campos de Contraseña
<div class="form-group">
<label for="nueva-password">Nueva Contraseña</label>
<div class="password-wrapper">
<input
type="password"
id="nueva-password"
name="nueva-password"
aria-describedby="requisitos-password"
autocomplete="new-password"
minlength="8"
>
<button
type="button"
aria-label="Mostrar contraseña"
aria-pressed="false"
onclick="togglePasswordVisibility(this)"
>
<svg aria-hidden="true"><!-- ícono de ojo --></svg>
</button>
</div>
<ul id="requisitos-password" class="requirements-list">
<li>Al menos 8 caracteres</li>
<li>Al menos una letra mayúscula</li>
<li>Al menos un número</li>
<li>Al menos un carácter especial</li>
</ul>
</div>
Implementaciones Específicas por Framework
React
// Componente de formulario accesible
function ContactForm() {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
return (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="nombre">Nombre</label>
<input
type="text"
id="nombre"
name="nombre"
value={formData.nombre || ''}
onChange={handleChange}
aria-invalid={errors.nombre ? 'true' : 'false'}
aria-describedby={errors.nombre ? 'nombre-error' : undefined}
/>
{errors.nombre && (
<span id="nombre-error" className="error" role="alert">
{errors.nombre}
</span>
)}
</div>
{/* Grupo de radio con legend */}
<fieldset>
<legend>Método de Contacto Preferido</legend>
{['email', 'telefono', 'sms'].map((metodo) => (
<div key={metodo} className="radio-option">
<input
type="radio"
id={`contacto-${metodo}`}
name="metodoContacto"
value={metodo}
checked={formData.metodoContacto === metodo}
onChange={handleChange}
/>
<label htmlFor={`contacto-${metodo}`}>
{metodo.charAt(0).toUpperCase() + metodo.slice(1)}
</label>
</div>
))}
</fieldset>
<button type="submit">Enviar</button>
</form>
);
}
Vue.js
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label :for="'email-' + uid">Correo Electrónico</label>
<input
:id="'email-' + uid"
v-model="form.email"
type="email"
:aria-invalid="errors.email ? 'true' : 'false'"
:aria-describedby="errors.email ? 'email-error-' + uid : undefined"
/>
<span
v-if="errors.email"
:id="'email-error-' + uid"
class="error"
role="alert"
>
{{ errors.email }}
</span>
</div>
<fieldset>
<legend>Tipo de Suscripción</legend>
<div
v-for="opcion in opcionesSuscripcion"
:key="opcion.value"
class="radio-option"
>
<input
:id="'sub-' + opcion.value + '-' + uid"
v-model="form.suscripcion"
type="radio"
name="suscripcion"
:value="opcion.value"
/>
<label :for="'sub-' + opcion.value + '-' + uid">
{{ opcion.label }}
</label>
</div>
</fieldset>
<button type="submit">Suscribirse</button>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const uid = computed(() => Math.random().toString(36).substr(2, 9))
const form = ref({ email: '', suscripcion: '' })
const errors = ref({})
</script>
Testing de Etiquetas de Formulario
Testing Automatizado
// Cypress con axe-core
describe('Accesibilidad de Formulario', () => {
beforeEach(() => {
cy.visit('/contacto');
cy.injectAxe();
});
it('no debe tener violaciones de etiquetas', () => {
cy.checkA11y(null, {
rules: {
'label': { enabled: true },
'label-title-only': { enabled: true },
'form-field-multiple-labels': { enabled: true }
}
});
});
it('debe asociar etiquetas con inputs', () => {
cy.get('label[for="email"]').should('exist');
cy.get('#email').should('exist');
cy.get('label[for="email"]').click();
cy.get('#email').should('be.focused');
});
});
Comandos de Testing con Lectores de Pantalla
Probando asociaciones de etiquetas:
VoiceOver (macOS):
- Tab al input → Debe anunciar texto de etiqueta
- VO + F → Leer controles de formulario
- VO + Command + J → Saltar al siguiente control
NVDA (Windows):
- F → Saltar al siguiente campo de formulario
- NVDA + F7 → Lista de elementos (seleccionar Campos de formulario)
- Tab → Navegar y escuchar etiquetas
JAWS (Windows):
- F → Saltar al siguiente campo de formulario
- Insert + F5 → Lista de controles de formulario
- Tab → Navegar con anuncio de etiqueta
Checklist de Testing Manual
## Auditoría de Etiquetas de Formulario
### Para Cada Input:
- [ ] Tiene etiqueta asociada (verificar en panel de accesibilidad DevTools)
- [ ] La etiqueta es descriptiva y clara
- [ ] Click en etiqueta enfoca el input
- [ ] Campos requeridos están indicados
- [ ] Mensajes de error están asociados con inputs
### Para Grupos de Campos:
- [ ] Campos relacionados agrupados con <fieldset>
- [ ] Grupos tienen elemento <legend>
- [ ] Grupos de radio/checkbox están correctamente etiquetados
### Para Etiquetas Ocultas:
- [ ] Usa clase sr-only (no display:none)
- [ ] Aún es anunciada por lectores de pantalla
- [ ] Contexto proporcionado via aria-label o aria-labelledby
Manejo de Errores y Validación
Mensajes de Error Accesibles
<div class="form-group" :class="{ 'has-error': hasError }">
<label for="email">Correo Electrónico</label>
<input
type="email"
id="email"
name="email"
:aria-invalid="hasError"
:aria-describedby="hasError ? 'email-error' : null"
>
<p
v-if="hasError"
id="email-error"
class="error-message"
role="alert"
>
Por favor ingresa un correo electrónico válido.
</p>
</div>
Anuncios de Validación en Vivo
// Anunciar errores de validación a lectores de pantalla
function announceError(fieldId, message) {
const liveRegion = document.getElementById('live-announcements');
liveRegion.textContent = `Error: ${message}`;
// Limpiar después del anuncio
setTimeout(() => {
liveRegion.textContent = '';
}, 1000);
}
<div
id="live-announcements"
aria-live="polite"
aria-atomic="true"
class="sr-only"
></div>
Resumen de Mejores Prácticas
Hacer
- Siempre usar elementos
<label>cuando sea posible - Usar IDs únicos para cada control de formulario
- Agrupar campos relacionados con
<fieldset>y<legend> - Proporcionar texto de etiqueta claro y descriptivo
- Asociar mensajes de error con sus inputs
- Probar con lectores de pantalla reales
No Hacer
- No usar placeholders como etiquetas
- No ocultar etiquetas con
display:none - No confiar solo en proximidad visual
- No usar IDs duplicados
- No olvidar indicadores de campos requeridos
- No saltarse fieldset/legend para grupos de radio/checkbox
Artículos Relacionados
- Hub de Componentes Accesibles - Guía completa de formularios
- Guía de Etiquetas ARIA - Etiquetado avanzado
Referencias
- W3C - WCAG 2.2 SC 1.3.1 Información y Relaciones
- W3C - WCAG 2.2 SC 3.3.2 Etiquetas o Instrucciones
- WebAIM - Creando Formularios Accesibles
- MDN - El Elemento Label
- W3C - Tutorial de Formularios WAI
- W3C - ARIA en HTML