Creación de formularios accesibles que realmente funcionan
Crea formularios que funcionen para todos: etiquetas ARIA correctas, gestión de errores, navegación por teclado y lectores de pantalla. Usabilidad real.

Los formularios son donde los usuarios te entregan su información, y donde los fallos de accesibilidad más daño hacen. Un usuario de lector de pantalla que no logra entender qué espera un campo, un usuario de teclado que no puede alcanzar el botón de envío, un usuario con baja visión que no puede ver el mensaje de error: estos no son casos extremos. Son personas intentando darte dinero, registrarse en tu servicio o enviar información crítica.
La mayoría de los formularios "accesibles" pasan las verificaciones automáticas pero fallan con usuarios reales. El enfoque de cumplimiento por casillas—"cada input tiene una etiqueta"—ignora la experiencia. Los formularios verdaderamente accesibles se comunican con claridad, gestionan los errores con elegancia y funcionan con cualquier método de entrada.
Etiquetas que comunican la intención
La primera regla de los formularios accesibles: cada input debe tener una etiqueta asociada programáticamente. Pero la asociación por sí sola no basta: la etiqueta debe comunicar lo que el campo espera.
<!-- ❌ Label exists but doesn't help -->
<label for="field1">Field 1</label>
<input id="field1" type="text" />
<!-- ❌ Placeholder is not a label — disappears on input -->
<input type="email" placeholder="Enter your email" />
<!-- ✅ Clear label with format hint -->
<div class="field-group">
<label for="phone">Phone number</label>
<input
id="phone"
type="tel"
aria-describedby="phone-hint"
autocomplete="tel"
/>
<span id="phone-hint" class="hint">
Format: (555) 123-4567
</span>
</div>
<!-- ✅ Required fields clearly indicated -->
<div class="field-group">
<label for="email">
Email address
<span aria-hidden="true" class="required">*</span>
</label>
<input
id="email"
type="email"
required
aria-required="true"
autocomplete="email"
aria-describedby="email-hint"
/>
<span id="email-hint" class="hint">
We'll send your confirmation here
</span>
</div>El atributo aria-describedby vincula texto complementario al input. Los lectores de pantalla lo anuncian después de la etiqueta, dando a los usuarios el contexto completo: "Email address, required. Edit text. We'll send your confirmation here."
Gestión de errores que guía
Los mensajes de error deben llegar a tres audiencias simultáneamente: usuarios videntes que exploran visualmente, usuarios de lectores de pantalla que navegan programáticamente y usuarios de teclado que recorren el formulario con Tab.
<!-- ✅ Accessible error handling pattern -->
<form novalidate aria-label="Registration form">
<!-- Error summary at top of form -->
<div
id="error-summary"
role="alert"
aria-live="assertive"
class="error-summary"
hidden
>
<h2>There are 2 problems with your submission</h2>
<ul>
<li><a href="#email">Enter a valid email address</a></li>
<li><a href="#password">Password must be at least 8 characters</a></li>
</ul>
</div>
<div class="field-group">
<label for="email">Email address</label>
<input
id="email"
type="email"
required
aria-required="true"
aria-invalid="true"
aria-describedby="email-error"
autocomplete="email"
/>
<span id="email-error" class="error" role="alert">
Enter a valid email address
</span>
</div>
<div class="field-group">
<label for="password">Password</label>
<input
id="password"
type="password"
required
aria-required="true"
aria-invalid="true"
aria-describedby="password-error password-requirements"
autocomplete="new-password"
/>
<span id="password-error" class="error" role="alert">
Password must be at least 8 characters
</span>
<span id="password-requirements" class="hint">
At least 8 characters with one uppercase letter and one number
</span>
</div>
</form>// Form validation with accessible error handling
class AccessibleForm {
private form: HTMLFormElement;
private errorSummary: HTMLElement;
constructor(form: HTMLFormElement) {
this.form = form;
this.errorSummary = form.querySelector('#error-summary')!;
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
}
private handleSubmit(e: Event): void {
e.preventDefault();
const errors = this.validate();
if (errors.length > 0) {
this.showErrors(errors);
} else {
this.clearErrors();
this.submitForm();
}
}
private showErrors(
errors: Array<{ fieldId: string; message: string }>
): void {
// Show error summary
this.errorSummary.hidden = false;
const list = this.errorSummary.querySelector('ul')!;
list.innerHTML = '';
for (const error of errors) {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${error.fieldId}`;
link.textContent = error.message;
li.appendChild(link);
list.appendChild(li);
// Mark individual fields
const field = document.getElementById(error.fieldId);
if (field) {
field.setAttribute('aria-invalid', 'true');
const errorEl = document.getElementById(`${error.fieldId}-error`);
if (errorEl) {
errorEl.textContent = error.message;
errorEl.hidden = false;
}
}
}
// Move focus to error summary so screen readers announce it
this.errorSummary.focus();
}
private clearErrors(): void {
this.errorSummary.hidden = true;
this.form.querySelectorAll('[aria-invalid]').forEach((el) => {
el.removeAttribute('aria-invalid');
});
}
}Navegación por teclado que fluye
Cada interacción de tu formulario debe funcionar sin ratón. Esto significa un orden de tabulación lógico, indicadores de foco visibles y controles personalizados accesibles por teclado.
/* ✅ Visible focus indicators — never remove outline without replacement */
/* ❌ Don't do this: *:focus { outline: none; } */
input:focus,
select:focus,
textarea:focus,
button:focus {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* For browsers that support it, only show focus ring on keyboard nav */
input:focus:not(:focus-visible) {
outline: none;
}
input:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* Error state styling */
input[aria-invalid="true"] {
border-color: #dc2626;
border-width: 2px;
}
input[aria-invalid="true"]:focus {
outline-color: #dc2626;
}
/* Skip link — first focusable element on the page */
.skip-link {
position: absolute;
top: -100%;
left: 0;
padding: 0.5rem 1rem;
background: #1e293b;
color: white;
z-index: 100;
}
.skip-link:focus {
top: 0;
}// ✅ Custom dropdown that works with keyboard
class AccessibleSelect {
private button: HTMLButtonElement;
private listbox: HTMLUListElement;
private options: HTMLLIElement[];
private activeIndex: number = -1;
handleKeyDown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex = Math.min(
this.activeIndex + 1,
this.options.length - 1
);
this.updateActiveDescendant();
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex = Math.max(this.activeIndex - 1, 0);
this.updateActiveDescendant();
break;
case 'Enter':
case ' ':
event.preventDefault();
if (this.activeIndex >= 0) {
this.selectOption(this.activeIndex);
}
break;
case 'Escape':
this.closeDropdown();
this.button.focus();
break;
case 'Home':
event.preventDefault();
this.activeIndex = 0;
this.updateActiveDescendant();
break;
case 'End':
event.preventDefault();
this.activeIndex = this.options.length - 1;
this.updateActiveDescendant();
break;
}
}
private updateActiveDescendant(): void {
const activeOption = this.options[this.activeIndex];
this.listbox.setAttribute(
'aria-activedescendant',
activeOption.id
);
activeOption.scrollIntoView({ block: 'nearest' });
}
}Formularios de varios pasos y progreso
Los formularios complejos divididos en varios pasos necesitan indicadores de progreso claros y la capacidad de navegar entre pasos.
<!-- ✅ Accessible multi-step form -->
<div role="group" aria-label="Checkout form">
<!-- Step indicator -->
<nav aria-label="Checkout progress">
<ol class="steps">
<li aria-current="step">
<span class="step-number">1</span>
<span>Shipping</span>
</li>
<li>
<span class="step-number">2</span>
<span>Payment</span>
</li>
<li>
<span class="step-number">3</span>
<span>Review</span>
</li>
</ol>
</nav>
<!-- Current step -->
<section aria-label="Step 1: Shipping information">
<h2>Shipping information</h2>
<!-- Live region announces step changes -->
<div
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
Step 1 of 3: Shipping information
</div>
<!-- Form fields for this step -->
<div class="field-group">
<label for="address">Street address</label>
<input
id="address"
type="text"
required
autocomplete="street-address"
/>
</div>
<div class="button-group">
<button type="button" disabled>Previous</button>
<button type="button" onclick="nextStep()">
Continue to payment
</button>
</div>
</section>
</div>Probar la accesibilidad de verdad
Las herramientas automáticas detectan alrededor del 30 % de los problemas de accesibilidad. El resto requiere pruebas manuales con tecnología de asistencia real.
## Accessibility testing checklist for forms
### Automated (run first)
- [ ] axe-core or Lighthouse audit: zero violations
- [ ] HTML validator: no markup errors
- [ ] All inputs have associated labels
- [ ] All images have alt text
### Keyboard testing (5 minutes)
- [ ] Tab through entire form — logical order?
- [ ] Every interactive element reachable via Tab
- [ ] Focus indicator visible on every element
- [ ] Custom controls work with Enter/Space
- [ ] Dropdown menus work with arrow keys
- [ ] Escape closes dropdowns/modals
- [ ] No keyboard traps (can always Tab out)
### Screen reader testing (15 minutes)
- [ ] NVDA or VoiceOver: navigate form by tab
- [ ] Each field announces: label, type, required state
- [ ] Error messages announced when they appear
- [ ] Error summary focused and read on submit
- [ ] Format hints read after label
- [ ] Multi-step progress communicated
### Visual testing
- [ ] Errors visible without relying on color alone
- [ ] Text meets 4.5:1 contrast ratio
- [ ] Form usable at 200% zoom
- [ ] Form works in high contrast modeConclusiones clave
Cada campo de un formulario debe tener una etiqueta asociada programáticamente mediante los atributos for/id: los placeholders no son etiquetas porque desaparecen cuando el usuario empieza a escribir, y los lectores de pantalla pueden no anunciarlos de forma consistente. La gestión de errores necesita tres capas: un resumen de errores en la parte superior del formulario que recibe el foco al enviar, mensajes de error individuales vinculados a los campos mediante aria-describedby, y atributos aria-invalid en los campos con problemas para que la tecnología de asistencia comunique el estado. Nunca elimines los contornos de foco sin ofrecer un reemplazo visible: usa :focus-visible para mostrar los anillos de foco solo durante la navegación por teclado, manteniendo el formulario limpio para los usuarios de ratón sin dejar de ser navegable para los usuarios de teclado. Prueba con tecnología de asistencia real, no solo con escáneres automáticos: recorre cada formulario con el teclado, navega por él con un lector de pantalla y amplía al 200 %, porque las herramientas automáticas como Lighthouse detectan aproximadamente el 30 % de los problemas de accesibilidad, mientras que las pruebas manuales revelan las brechas de experiencia que más importan.


