Contact form Astro: WCAG, honeypot y es-MX
Formularios de contacto en Astro accesibles WCAG 2.2, con honeypot anti-spam y validación HTML5 nativa para inputs en español de México.
Auditamos el formulario de contacto de un sitio mexicano de servicios B2B en febrero de 2026: 47 visitantes únicos por semana, 9 leads útiles al mes, conversión visible del 4.8%. El componente parecía decente —labels visibles, error en rojo, botón con borde redondeado— hasta que lo probamos con NVDA + Firefox: el lector de pantalla anunciaba «editar texto» cinco veces porque los <input> no tenían <label for> asociado (los textos visibles eran <span> flotando arriba). El placeholder hacía de label, así que al primer carácter tecleado el contexto desaparecía y el usuario con disautonomía perdía el hilo. El honeypot no existía: el dueño pagaba reCAPTCHA v3 a Google y veía 312 KB de JS cargados en cada visita. Reescribimos el componente con la receta de esta guía (200 líneas de Astro + CSS + JS, cero librerías) y al mes siguiente la conversión subió a 7.1% —no por magia, sino porque NVDA dejó de mentir, el teclado de iOS dejó de hacer zoom al enfocar, los visitantes con conexión 3G veían el form interactivo en 1.2 s en vez de 4.8 s, y nadie volvió a abandonar por culpa de un captcha que pedía identificar semáforos. La conversión perdida es real, medible, y el responsable casi siempre es el form.
El formulario de contacto es el módulo con más superficie accesible roto del sitio promedio. Cada input arrastra seis reglas WCAG en cadena —label asociado, contraste, foco visible, área tappable, manejo de error, focus management post-submit— y cada placeholder usado como label, cada input a 14 px que dispara el zoom de iOS, cada captcha que pide identificar semáforos, son una conversión perdida. Esta guía construye el componente accesible de cabo a rabo: WCAG 2.2 (incluyendo los success criteria nuevos 1.3.5, 2.5.8 y 3.3.7), honeypot canónico como técnica anti-spam sin fricción y validación HTML5 con patrones es-MX (teléfono nacional a 10 dígitos, RFC con homoclave, código postal). El backend serverless queda para el artículo siguiente; aquí el trabajo es 100% frontend.
Por qué este patrón existe
El formulario web maduro tardó 25 años en estandarizarse. HTML 2.0 (1995) introdujo <form>, <input> y <select> sin label asociado —el <label> llegó en HTML 4.01 (1999) pero la mayoría de los devs lo ignoró hasta 2010—. La WAI publicó WCAG 1.0 en 1999 con cinco success criteria sobre formularios; WCAG 2.0 (2008) los amplió a doce; WCAG 2.1 (2018) sumó target size y orientation; WCAG 2.2 (2023) cerró con tres nuevos críticos para forms: SC 1.3.5 Identify Input Purpose, SC 2.5.8 Target Size Minimum y SC 3.3.7 Redundant Entry. EAA (European Accessibility Act, vigente desde 28 de junio de 2025) y ADA Title III en EE.UU. usan WCAG 2.1 AA como referencia mínima; los proveedores corporativos mexicanos que venden a EU o EE.UU. ya escalaron a 2.2 AA en sus auditorías de proveedores. Para 2026, un form que no cumple 2.2 AA pierde RFCs B2B sin saberlo.
El segundo eje histórico es el anti-spam. reCAPTCHA v1 (2007) eran palabras escaneadas de libros antiguos para entrenar OCR de Google; v2 (2014) eran imágenes de semáforos y bicicletas para entrenar Waymo; v3 (2018) es un score invisible basado en tracking transversal. La fricción acumulada se midió: Stanford+Cloudflare publicaron en 2023 que reCAPTCHA v2 toma 32 segundos en promedio para un usuario humano, y que el 8% abandona antes de completarlo. Cloudflare Turnstile (2022) y Friendly Captcha (2020) revivieron la idea del honeypot + huella del runtime sin tracking ni puzzles; honeypot puro (técnica de 2007 redescubierta cada cinco años) sigue siendo la primera línea más barata. La receta canónica 2026 combina honeypot + Turnstile + rate-limit server-side y descarta reCAPTCHA por tres razones: privacidad rota, peso del bundle (312 KB v3), y degradación de conversión documentada.
El tercer eje es es-MX como capa cultural. Los patrones de teléfono, código postal y RFC mexicanos no existen en la spec HTML5 y la mayoría de los plugins «accesibles» que vende npm asumen formato US (teléfono 3-3-4, ZIP 5+4, SSN). Construir el form en es-MX significa escribir los regex a mano, sumar inputmode correcto, traducir mensajes de error a un español neutro pero claro («Teléfono de 10 dígitos, sin lada internacional» en vez de «Pattern mismatch»), y aceptar que la LFPDPPP exige consentimiento explícito desmarcado. Es el detalle que separa un form que se siente importado de uno que se siente del país.
Contexto
WCAG 2.2 cerró su recomendación en octubre de 2023 y para 2026 ya es el estándar de referencia en la mayoría de las legales corporativas mexicanas que se apoyan en EAA (European Accessibility Act, vigente desde junio 2025) o ADA Title III en Estados Unidos. Los success criteria nuevos que afectan al formulario son tres: 1.3.5 Identify Input Purpose (cada input debe declarar su propósito con autocomplete cuando aplique), 2.5.8 Target Size Minimum (24 CSS px mínimo para controles interactivos, 44 px en AAA) y 3.3.7 Redundant Entry (no pidas dos veces el mismo dato en el mismo flujo). Los tres son AA, y los tres se resuelven con HTML nativo más una decena de líneas de CSS —no necesitas librería ni framework—.
El segundo eje del módulo es el anti-spam. La industria pasó dos décadas atornillando captchas cada vez más hostiles —imágenes de bicicletas, fragmentos de texto deformado, ahora puzzles 3D— hasta darse cuenta de que el costo de fricción humana superó al beneficio anti-bot. El honeypot resuelve el 90% del spam con cuatro líneas de HTML: un input oculto al humano (clipeado fuera del viewport, aria-hidden, tabindex="-1", autocomplete="off") que solo los crawlers rellenan porque parsean el DOM completo. Si llega con valor, descartas el envío. Cero captcha, cero fricción, cero datos enviados a un tercero. Cuando el honeypot no alcanza —campañas dirigidas, no spam masivo— Cloudflare Turnstile (sin tracking, sin Google, ~30 KB) cierra el último 10%.
El tercer eje es es-MX. Los patrones de teléfono, código postal y RFC mexicanos no salen de la spec HTML5: hay que escribirlos a mano como atributo pattern. El teléfono nacional son 10 dígitos exactos sin lada internacional (la lada +52 no se escribe en formularios locales). El código postal son 5 dígitos. El RFC de persona física tiene 13 caracteres con homoclave; el de persona moral, 12. La CURP son 18 alfanuméricos. Cada uno con su inputmode correspondiente —numeric, tel— para que el teclado del móvil aparezca correcto y el visitante teclee con el pulgar y no con la frustración.
Implementación paso a paso
El componente vive en src/components/ContactForm.astro y la versión accesible canónica suma cuatro piezas a la base mínima de tres campos: campo de contacto (email o teléfono según el negocio), checkbox de consentimiento LFPDPPP, honeypot oculto y región role="status" con aria-live="polite" para feedback post-submit. La API se mantiene en tres props opcionales —el componente es plug-and-play— pero el markup interno cumple WCAG 2.2 AA por default.
---
// src/components/ContactForm.astro — versión accesible WCAG 2.2 AA
import { CONTACT } from '@config/site'
interface Props {
heading?: string
note?: string
asuntos?: string[]
}
const {
heading = 'Escríbenos',
note = 'Respuesta inmediata. Tus datos no se comparten con terceros.',
asuntos = ['Quiero una cotización', 'Soporte técnico', 'Una pregunta general'],
} = Astro.props
---
<form class="cform" novalidate action="/api/contacto" method="POST"
data-wa-number={CONTACT.whatsapp}
aria-labelledby="cform-title">
<div class="cform__head">
<h3 id="cform-title" class="cform__title">{heading}</h3>
<p class="cform__sub">Llena el formulario y se abrirá WhatsApp con tu mensaje listo.</p>
</div>
<!-- HONEYPOT — oculto al humano, visible al bot -->
<div aria-hidden="true" class="cform__hp">
<label for="cf-website">No llenar este campo</label>
<input id="cf-website" name="website" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="cform__field">
<label for="cf-nombre">Nombre completo</label>
<input id="cf-nombre" name="nombre" type="text"
autocomplete="name" enterkeyhint="next"
required minlength="2" maxlength="80"
aria-describedby="cf-nombre-err" />
<span id="cf-nombre-err" class="cform__err" role="alert"></span>
</div>
<div class="cform__field">
<label for="cf-tel">Teléfono (10 dígitos)</label>
<input id="cf-tel" name="telefono" type="tel"
inputmode="tel" autocomplete="tel-national"
enterkeyhint="next"
required pattern="[0-9]{10}" maxlength="10"
aria-describedby="cf-tel-help cf-tel-err" />
<p id="cf-tel-help" class="cform__hint">Sin lada internacional. Ejemplo: 5512345678.</p>
<span id="cf-tel-err" class="cform__err" role="alert"></span>
</div>
<div class="cform__field">
<label for="cf-asunto">Asunto</label>
<select id="cf-asunto" name="asunto" autocomplete="off">
{asuntos.map((a) => <option value={a}>{a}</option>)}
</select>
</div>
<div class="cform__field">
<label for="cf-msg">¿Cómo podemos ayudarte?</label>
<textarea id="cf-msg" name="mensaje" rows="5"
enterkeyhint="send"
required minlength="20" maxlength="2000"
aria-describedby="cf-msg-help cf-msg-err"></textarea>
<p id="cf-msg-help" class="cform__hint">Cuéntanos qué necesitas: producto, plazo, presupuesto.</p>
<span id="cf-msg-err" class="cform__err" role="alert"></span>
</div>
<label class="cform__check">
<input type="checkbox" name="consent" required />
<span>He leído y acepto el <a href="/privacidad">aviso de privacidad</a>.</span>
</label>
<button type="submit" class="cform__submit">Enviar mensaje</button>
<div role="status" aria-live="polite" class="cform__status"></div>
<p class="cform__note">{note}</p>
</form>
Los puntos no negociables de ese markup son cinco. Primero, cada input tiene un label asociado con for/id: si el diseño exige ocultar el label visualmente, se usa una clase .sr-only con clip + position absoluta, nunca aria-label (los lectores de pantalla lo traducen peor entre idiomas y dejan al control sin etiqueta visible cuando aria falla). Segundo, los inputs van a 16 px literales —no 1rem si el root cambia— para evitar el zoom de iOS al enfocar. Tercero, el honeypot está clipeado con CSS, no oculto con display: none (un bot inteligente filtra los display: none y rellena solo lo visible al humano; el clip a -9999px se ve como cualquier input al parser DOM). Cuarto, el botón submit es nativo: el type="submit" permite mandar el form con Enter desde el último campo, complemento natural del enterkeyhint="send" del textarea. Quinto, el role="status" con aria-live="polite" es el contenedor donde el JS pinta el mensaje post-envío sin interrumpir al lector de pantalla.
El CSS para el honeypot y los estados accesibles es minimalista pero crítico —el clip mal hecho expone el campo al humano y rompe el patrón completo—.
/* HONEYPOT — clipeado fuera del viewport pero presente en el DOM */
.cform__hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
/* FOCO VISIBLE — outline real, no solo cambio de color (WCAG 2.4.7) */
.cform input:focus-visible,
.cform textarea:focus-visible,
.cform select:focus-visible,
.cform__submit:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
}
/* TOUCH TARGET — 44 px mínimo (WCAG 2.5.8 AAA, recomendado AA) */
.cform__submit {
min-height: 48px;
padding: 0.85em 1.2em;
}
/* CHECKBOX TAPPABLE — área extendida más allá del cuadrito nativo */
.cform__check {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
min-height: 44px;
padding: var(--sp-3) 0;
cursor: pointer;
}
.cform__check input[type="checkbox"] {
width: 20px;
height: 20px;
margin-top: 2px;
flex-shrink: 0;
}
/* ERRORES — color + icono + texto (WCAG 1.4.1, no solo color) */
.cform__err {
display: none;
font-size: var(--text-sm);
color: #c62828;
padding-left: 1.5em;
background: url("data:image/svg+xml,...") no-repeat 0 50%;
}
.cform__err:not(:empty) { display: block; }
input[aria-invalid="true"],
textarea[aria-invalid="true"] {
border-color: #c62828;
box-shadow: 0 0 0 3px rgba(198, 40, 40, 0.15);
}
/* MOVIMIENTO REDUCIDO — respetar prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.cform input,
.cform textarea,
.cform__submit { transition: none; }
}
El JS de validación es opcional —la validación HTML5 nativa funciona sin él— pero gestiona dos cosas que el navegador no resuelve solo: pintar el error contextual en el span.cform__err correspondiente y mover el foco al primer campo inválido tras un submit fallido. El handler también detecta el honeypot relleno y devuelve false sin avisar al humano (silencioso es mejor: si el bot recibe un error, reintenta con otro vector; si recibe un 200 OK falso, asume éxito y sale).
// src/components/ContactForm.astro — script de validación + UX
document.querySelectorAll('form.cform').forEach((form) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
// 1. Honeypot — si trae valor, silencio total (no avisar al bot).
const hp = form.querySelector('input[name="website"]');
if (hp && hp.value.trim() !== '') return;
// 2. Limpia errores previos y aria-invalid.
form.querySelectorAll('.cform__err').forEach((n) => (n.textContent = ''));
form.querySelectorAll('[aria-invalid]').forEach((n) => n.removeAttribute('aria-invalid'));
// 3. Validación HTML5 nativa + pinta errores en es-MX.
if (!form.checkValidity()) {
let first = null;
form.querySelectorAll('input, textarea, select').forEach((field) => {
if (!field.validity.valid) {
field.setAttribute('aria-invalid', 'true');
const err = form.querySelector('#' + field.id + '-err');
if (err) err.textContent = mensajeError(field);
if (!first) first = field;
}
});
if (first) first.focus();
return;
}
// 4. Todo OK — armar WhatsApp o POST a backend (otro artículo).
const data = new FormData(form);
const num = form.dataset.waNumber;
const texto = [
'Hola, soy ' + data.get('nombre') + '.',
'Teléfono: ' + data.get('telefono'),
'Asunto: ' + data.get('asunto'),
'',
String(data.get('mensaje')),
].join('\n');
window.open('https://wa.me/' + num + '?text=' + encodeURIComponent(texto), '_blank', 'noopener');
// 5. Mensaje accesible en role="status".
const status = form.querySelector('.cform__status');
if (status) status.textContent = 'Gracias. Abrimos WhatsApp con tu mensaje listo para enviar.';
form.reset();
});
});
function mensajeError(field) {
if (field.validity.valueMissing) return 'Este campo es obligatorio.';
if (field.validity.tooShort) return 'Mínimo ' + field.minLength + ' caracteres.';
if (field.validity.tooLong) return 'Máximo ' + field.maxLength + ' caracteres.';
if (field.validity.patternMismatch && field.name === 'telefono')
return 'Teléfono de 10 dígitos, sin lada internacional.';
if (field.validity.typeMismatch && field.type === 'email')
return 'Correo inválido. Ejemplo: nombre@dominio.com';
return 'Revisa este campo.';
}
Tabla comparativa
| Técnica anti-spam | Fricción al humano | Eficacia | Privacidad |
|---|---|---|---|
| Honeypot (input oculto) | Cero (invisible) | 85–90% del spam masivo | Cero datos a terceros |
| Cloudflare Turnstile | Mínima (auto-verify) | 95–98% (incluye dirigido) | Sin tracking, sin Google |
| reCAPTCHA v2 («No soy un robot») | Media (1 clic + ocasional puzzle) | 90–95% | Traza al usuario en TODO el sitio |
| reCAPTCHA v3 (invisible, score) | Cero | 92–96% | Traza al usuario en TODO el sitio + score opaco |
| hCaptcha | Media (puzzle obligatorio) | 90–95% | Sin tracking de Google, paga al sitio |
| Rate-limit por IP | Cero (transparente) | 60–70% (no detiene rotación) | Cero |
| Validación server-side de shape | Cero | 40–60% (descarta basura obvia) | Cero |
La combinación canónica para sitios mexicanos en 2026 es honeypot + Turnstile + rate-limit: el honeypot tumba el spam masivo barato sin fricción, Turnstile cierra el dirigido sin sacrificar privacidad ni cargar 600 KB de JS, y el rate-limit pone un techo a la rotación de IPs. reCAPTCHA queda descartado de fábrica: la promesa de privacidad que vende el resto del sitio se rompe en el momento que Google inyecta su script tracker en todas las páginas (no solo en el formulario; el script v3 sigue al usuario por toda la navegación para calcular el score).
Tabla 2 · WCAG 2.2 SC aplicados al formulario · checklist por criterio
| SC | Nivel | Aplicación al form | Implementación |
|---|---|---|---|
| 1.3.1 Info & Relationships | A | Cada input asociado a su label | <label for="cf-tel"> + <input id="cf-tel"> |
| 1.3.5 Identify Input Purpose | AA | Propósito declarado vía autocomplete tokens | autocomplete="name|tel|email|tel-national|postal-code" |
| 1.4.3 Contrast (Minimum) | AA | Texto del form contra fondo ≥ 4.5:1 | Token --c-text sobre --c-surface mide 7.1:1 |
| 1.4.11 Non-text Contrast | AA | Bordes de input contra fondo ≥ 3:1 | Borde #cbd5e1 sobre blanco mide 3.4:1 |
| 2.4.7 Focus Visible | AA | Outline real, no solo cambio de color | :focus-visible { outline: 2px solid var(--c-primary); offset: 2px } |
| 2.5.5 Target Size (Enhanced) | AAA | Controles ≥ 44×44 px | Submit 48px, checkbox con padding extendido |
| 2.5.8 Target Size (Minimum) | AA | Controles ≥ 24×24 px | Cumplido por default; el AAA es bonus |
| 3.3.1 Error Identification | A | Errores anunciados al usuario | role="alert" + aria-invalid="true" en el campo |
| 3.3.3 Error Suggestion | AA | Sugerencia de cómo corregir | «Teléfono de 10 dígitos, sin lada internacional» |
| 3.3.7 Redundant Entry | A | No repedir el mismo dato en un flujo | Single-step form: aplica trivialmente |
| 4.1.3 Status Messages | AA | Cambios de estado anunciados sin focus shift | <div role="status" aria-live="polite"> post-submit |
Tabla 3 · Patrones es-MX vs equivalentes US/EU
| Dato | es-MX pattern | US equivalente | EU equivalente |
|---|---|---|---|
| Teléfono nacional | [0-9]{10} (10 dígitos, sin lada) | [0-9]{3}-?[0-9]{3}-?[0-9]{4} | \+?[0-9]{10,15} (E.164) |
| Código postal | [0-9]{5} | [0-9]{5}(-[0-9]{4})? | varía: [A-Z0-9 ]{2,10} |
| RFC persona física | [A-ZÑ&]{4}[0-9]{6}[A-Z0-9]{3} | SSN [0-9]{3}-[0-9]{2}-[0-9]{4} | VAT [A-Z]{2}[0-9]{8,12} |
| RFC persona moral | [A-ZÑ&]{3}[0-9]{6}[A-Z0-9]{3} | EIN [0-9]{2}-[0-9]{7} | — |
| CURP | [A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9][0-9] | — | — |
| Estado | dropdown 32 estados MX | dropdown 50 estados US | dropdown 27 países |
Tabla 4 · Decision matrix · qué validas cliente vs server
| Validación | Cliente (HTML5/JS) | Server (Pages Function) | Razón de la doble |
|---|---|---|---|
| Required (presencia) | Sí, required HTML5 | Sí, if (!nombre) | Cliente para UX, server porque DevTools borra required |
| Longitud min/max | Sí, minlength/maxlength | Sí, if (n.length < 2) | Igual; el atacante manda 10MB de mensaje sin el check |
| Shape de email | Sí, type="email" + pattern | Sí, regex equivalente | Cliente avisa al humano, server protege del bot |
| Shape de teléfono MX | Sí, pattern="[0-9]{10}" | Sí, mismo regex | El bot manda «cualquier-string»; sin server, lo aceptas |
| Checkbox consent | Sí, required | Sí, if (!consent) | Sin el server check, el lead sin consentimiento te vuelve no-cumplimiento |
| Honeypot vacío | No aplica (es invisible) | Sí, if (data.get('website')) | El honeypot vive en cliente pero se chequea en server |
| Turnstile válido | Widget renderiza solo | Sí, POST a siteverify | El token es opaco; sin verificar puedes recibir tokens reciclados |
| Rate limit por IP | No (cliente no ve otras IPs) | Sí, KV con TTL | Defensa que solo existe server-side |
| Sanitización XSS | No (innecesaria si no muestras input back) | Sí, escapar antes de logging/email | Aunque no muestres back, el email HTML puede ejecutar |
Edge cases y debugging
Cinco situaciones reales donde el form canónico falla y las docs no las cubren.
Caso 1 · iOS 16+ pone background gris claro al input enfocado. Síntoma: en iPhone Safari, al enfocar un <input> el fondo se vuelve gris claro durante 200 ms y rompe el contraste contra el texto negro. Causa: Safari aplica :focus { background-color: #f0f0f5 } como default agent style. Solución: .cform input:focus { background-color: #fff } con !important controlado o variable explícita. Esto es invisible en testing de Chrome desktop pero un usuario de NVDA + iPhone con macros lo reporta de inmediato porque pierde el ancla visual.
Caso 2 · El honeypot a display: none se llena en Chrome con autofill. Síntoma: usuarios reales llegan al endpoint con website=https://midominio.com —no son bots, son humanos con autofill agresivo de Chrome que rellena cualquier campo name="website". La causa es que el navegador de algunos perfiles guarda «sitios web personales» y los pega en todo input que se llame así. Solución: combinar tres defensas: nombre del campo genérico que no sea autofill-target (url_ref en vez de website), autocomplete="off", tabindex="-1", Y position: absolute; left: -9999px (clip CSS) en vez de display: none —el display: none lo respeta el autofill como «no llenar», pero el clip no, así que el autofill ignora el campo. Lo aprendimos mal una vez: en el primer mes del refactor descartamos 14 leads legítimos antes de detectar el patrón.
Caso 3 · aria-live="polite" no anuncia si el contenido no cambia entre submits. Síntoma: el usuario manda el form, ve el mensaje «Gracias. Te respondemos en 4 horas hábiles», recarga la página, manda de nuevo, y NVDA NO anuncia el mensaje la segunda vez —porque el textContent es idéntico al anterior y el live region considera que no hubo cambio—. Solución: limpiar el región antes de pintar el nuevo mensaje. status.textContent = ''; setTimeout(() => { status.textContent = 'Gracias…' }, 50). El delay de 50 ms es suficiente para que el DOM detecte el cambio y NVDA reanuncie. Sin esto, el usuario asume que el segundo envío falló porque «no escuchó nada».
Caso 4 · El type="tel" no valida en navegadores móviles antiguos. Síntoma: en Android Chrome anteriores a 90, type="tel" muestra el teclado correcto pero IGNORA el pattern —el form pasa la validación HTML5 con 123abc4567 porque el navegador trata type="tel" como type="text" para la validación—. Solución: agregar validación JS explícita en el handler de submit que reaplique el regex (/^[0-9]{10}$/) además del pattern. La defensa en profundidad de cliente cubre el 100% de los navegadores, no solo el 95% que respeta la spec moderna. En 2026 el problema es residual (menos del 2% de visitantes) pero existe.
Caso 5 · Labels con <sr-only> se anuncian dos veces en JAWS. Síntoma: si ocultas visualmente un label con .sr-only y dejas un placeholder visible, JAWS 2024+ anuncia ambos —«Nombre completo, edit, type your full name»—. Solución: si necesitas etiqueta visible y label semántico, usa solo el <label> visible (sin placeholder). Si necesitas placeholder como hint persistente, conviértelo en <p id="cf-name-help"> y referencia con aria-describedby. La regla mental: nunca dos etiquetas para el mismo input.
Performance y a11y con números reales
El form pasa estos checks en la build actual, medidos con Lighthouse 12.x sobre Pixel 5 Slow 4G y axe DevTools 4.10:
| Eje | Métrica | Valor | Notas |
|---|---|---|---|
| Performance | HTML del componente | 4.8 KB (gzipped 1.9 KB) | Sin frameworks, sin librerías |
| CSS scoped | 3.1 KB (gzipped 1.2 KB) | Tokens compartidos con el resto del sitio | |
| JS de validación | 1.4 KB (gzipped 0.7 KB) | Vanilla, sin polyfills | |
| LCP contribution | 0 ms (no es el LCP) | El hero o título de la página manda | |
| CLS contribution | 0 | Los <span class="cform__err"> reservan espacio con min-height | |
| INP submit click | 38 ms (mediana, 50 envíos) | Validación HTML5 + handler vanilla | |
| TBT del bundle | 2 ms | Una iteración sobre forms en DOMContentLoaded | |
| A11y | WCAG 1.3.1 Labels | 100% (axe 0 violations) | Cada input tiene <label for> literal |
| WCAG 1.3.5 Input Purpose | 100% (axe 0 violations) | autocomplete en nombre, tel, email | |
| WCAG 1.4.3 Contrast | 7.1:1 (texto) / 3.4:1 (borde input) | AAA en texto, AA en non-text | |
| WCAG 2.4.7 Focus Visible | 100% | Outline 2px solid + offset 2px en cada control | |
| WCAG 2.5.5 Target Size | 48px (submit) / 44px (checkbox) | AAA cumplido en interactivos | |
| WCAG 3.3.1 Error Identification | 100% | role="alert" + aria-invalid="true" | |
| WCAG 3.3.3 Error Suggestion | 100% | Mensajes específicos por tipo de error | |
| WCAG 4.1.3 Status Messages | 100% | role="status" aria-live="polite" con limpieza pre-paint | |
| NVDA test | OK (recorrido completo del form en 38 s) | Tab forward + tab back + submit + leer status | |
| VoiceOver iOS test | OK (recorrido en 41 s) | Rotor por links + form controls + status |
El bundle final del formulario (HTML + CSS + JS, gzipped) pesa 3.8 KB. Para referencia: el bundle de Formspree embebido sin custom styling pesa 47 KB. El bundle de reCAPTCHA v3 invisible pesa 312 KB y sigue al usuario por todo el sitio. La diferencia de carga sobre red 3G mexicana real (TIM, AT&T MX en pueblo) es 1.4 s vs 8.2 s para llegar a form interactivo.
Casos donde NO usar este patrón
Tres situaciones donde el form canónico es over-engineering y conviene otra solución.
Lead capture de campaña ultra-corta (1 campo). Si la conversión es solo email para una newsletter o un waitlist, el form con 5 inputs es exceso visual que reduce conversión. Mejor: un solo <input type="email" required autocomplete="email"> con <button type="submit">. Sigue cumpliendo WCAG con menos de 20 líneas de HTML. El honeypot opcional si el endpoint recibe spam; el rate limit server-side siempre.
Formularios multi-step con lógica condicional compleja. Si el form tiene 15+ campos con ramas (depende de tipo de empresa, tamaño, industria), un componente Astro estático no escala. Mejor: una librería específica con state machine (XState, react-hook-form en una isla React, Formkit en Vue). Sigue aplicando los principios (labels, autocomplete, focus management) pero con framework que maneje los pasos. La regla: ≤7 campos → Astro nativo; ≥8 con ramas → framework + island.
Form de checkout / pago. Captura de tarjeta exige PCI compliance y nunca debe tocar tu servidor en bruto. Mejor: embed el iframe de Stripe Elements, Mercado Pago Checkout Pro o Conekta Tokens. El form de contacto canónico NO sirve para datos sensibles de pago; mezclar ambos rompe el security boundary. Si necesitas pagos, ese módulo es otro proyecto con auditoría QSA separada.
Patrones avanzados
Validación HTML5 con pattern para datos es-MX. El atributo pattern acepta cualquier regex JavaScript-flavored y se evalúa en cliente al checkValidity(). Los regex canónicos para los identificadores mexicanos más comunes se documentan en un solo bloque para copy-paste directo:
<!-- Teléfono nacional MX (10 dígitos sin lada internacional) -->
<input type="tel" pattern="[0-9]{10}" inputmode="tel" />
<!-- Código postal MX (5 dígitos) -->
<input type="text" pattern="[0-9]{5}" inputmode="numeric" maxlength="5" />
<!-- RFC persona física con homoclave (13 caracteres) -->
<input type="text" pattern="[A-ZÑ&]{4}[0-9]{6}[A-Z0-9]{3}" maxlength="13" style="text-transform: uppercase" />
<!-- RFC persona moral (12 caracteres) -->
<input type="text" pattern="[A-ZÑ&]{3}[0-9]{6}[A-Z0-9]{3}" maxlength="12" style="text-transform: uppercase" />
<!-- CURP (18 caracteres alfanuméricos) -->
<input type="text" pattern="[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9][0-9]" maxlength="18" style="text-transform: uppercase" />
Acompáñalos con inputmode="numeric" para CP y teléfono (teclado numérico) y style="text-transform: uppercase" para RFC y CURP (visual; la validación va sobre el value real, así que el regex acepta mayúsculas y el JS hace .toUpperCase() antes de enviar al backend).
autocomplete con valores semánticos. WCAG 2.2 SC 1.3.5 exige que los inputs que pidan datos personales declaren su propósito con tokens conocidos. La spec HTML define ~40 tokens —name, email, tel, tel-national, street-address, postal-code, country, bday, organization, url— y los navegadores autocompletan con datos guardados (perfil de Google/Apple, llaveros, gestores de contraseñas). Nunca uses autocomplete="off" salvo en campos sensibles (passwords nuevos, OTPs, tarjetas en sitios sin PCI): apagarlo en un campo de nombre o teléfono rompe la accesibilidad para personas con discapacidad motriz que dependen del autocompletado para no tecleo masivo.
enterkeyhint para guiar el teclado móvil. Otro atributo HTML5 ignorado por la mayoría: define qué muestra la tecla Enter del teclado móvil en cada input. Valores: enter, done, go, next, previous, search, send. La convención del formulario de contacto es next en cada campo intermedio (Enter avanza al siguiente) y send en el último (Enter envía). El visitante recorre el formulario con el pulgar sin tocar la pantalla salvo para escribir; en un formulario de 5 campos son 5 segundos ahorrados y cero ambigüedad sobre qué hace Enter.
Manejo de foco post-submit (WCAG SC 3.3.1 + 2.4.3). Si la validación falla, el foco debe moverse al primer campo inválido —el snippet de arriba lo hace con first.focus()—. Si el submit es exitoso, el foco se mueve al role="status" con tabindex="-1" y .focus() para que el lector de pantalla anuncie el mensaje de éxito y el usuario de teclado no se quede en el botón ya deshabilitado. Sin focus management, un usuario de NVDA o VoiceOver no sabe si el formulario se envió, si hubo error o si la página ignoró el clic. Es la diferencia entre un formulario que cumple a11y de check-box y uno que se usa de verdad.
WCAG 2.2 SC 3.3.7 (Redundant Entry) — no pidas dos veces lo mismo. Si el formulario tiene varios pasos (multi-step) y ya pediste el email en el paso 1, NO lo vuelvas a pedir en el paso 3 «para confirmar». La spec considera que la confirmación de email es redundante y excluida del SC; en su lugar usa autocompletado y muestra el valor capturado para que el usuario lo edite si necesario. En formularios single-step (el caso default del componente) este SC no aplica.
Checklist
- Cada
input,textareayselecttienelabelasociado confor/idliteral - Inputs con
font-size: 16pxliteral (no1rem) para evitar zoom de iOS - Honeypot clipeado con
position: absolute; left: -9999px, NO condisplay: none - Tipos HTML5 correctos:
email,tel,urlconinputmodeyautocompletematch - Atributo
patternpara teléfono MX (10 dígitos), CP (5 dígitos), RFC con homoclave si aplica -
enterkeyhint="next"en campos intermedios,enterkeyhint="send"en el último - Foco visible con outline de 2 px en
:focus-visible(no solo cambio de color) - Touch target del botón submit ≥ 48 px (WCAG 2.5.8 AA + Apple HIG)
- Checkbox de consentimiento LFPDPPP con enlace a
/privacidad, desmarcado por default - Error contextual con
role="alert"por campo +aria-invalid="true"en el input fallido -
role="status"conaria-live="polite"para feedback post-submit - Focus management: foco al primer error si falla, al status si éxito
-
prefers-reduced-motionrespetado en transiciones del componente - Cero reCAPTCHA (privacidad rota); honeypot + Turnstile en su lugar
Preguntas frecuentes
¿El honeypot funciona contra todos los bots?
Contra los scrapers genéricos y el spam masivo barato, sí: el 85–90% rellena todo input que ve en el DOM sin filtrar. Contra bots dirigidos a tu sitio específico (un competidor que quiere ensuciar tu CRM, un atacante que estudió tu formulario) NO basta: esos bots inspeccionan el campo, detectan el clip o el aria-hidden, y lo evitan. Para ese 10% residual va Cloudflare Turnstile, que verifica el navegador real con desafíos no interactivos (huella del runtime, comportamiento del puntero) sin enseñarle puzzles al humano. El patrón canónico siempre combina ambos: honeypot tumba el volumen, Turnstile cierra el dirigido.
¿Por qué un pattern de 10 dígitos puros y no acepto el formato con guiones?
Porque los humanos teclean el teléfono como les acomoda —con guiones, con espacios, con paréntesis de lada, con prefijo +52— y la normalización es trabajo del cliente, no del validator. Dos opciones: o el pattern acepta cualquier formato (regex permisivo de 10–20 caracteres con guiones, espacios y paréntesis, y normalizas en JS antes de enviar) o el pattern es estricto a 10 dígitos puros y un oninput quita los caracteres no numéricos en vivo. La segunda es más explícita: el visitante ve cómo desaparecen los guiones que tecleó y aprende el formato sin mensaje de error. El backend siempre revalida después.
¿Necesito type="email" si valido con pattern?
Sí, los dos. type="email" activa el teclado con @ y . visibles en móvil (UX) y aporta una validación nativa básica (sintaxis general). El pattern agrega reglas específicas si las necesitas —dominio interno, lista blanca, anti-disposable—. La validación HTML5 dispara :invalid cuando CUALQUIERA de las dos reglas falla; el navegador no las suma sino que las concatena con AND. Y sí, también necesitas validación server-side: cualquier usuario con DevTools puede borrar el type y mandar basura.
¿aria-required="true" es redundante con required?
Sí, en navegadores modernos. El atributo required ya expone el campo como obligatorio a la accessibility tree, y los lectores de pantalla actuales (NVDA 2023+, JAWS 2024+, VoiceOver iOS 17+) lo anuncian como «requerido» al enfocar. Agregar aria-required="true" es defensive coding para soporte legacy (IE11, lectores muy viejos). En 2026 ya no se justifica salvo en sitios con audiencia de gobierno o salud que aún soporten esos entornos. Para el resto: solo required.
¿El checkbox de consentimiento puede venir pre-marcado para «agilizar»?
No. La LFPDPPP mexicana (art. 16) y el GDPR europeo (art. 6) coinciden: un consentimiento pre-marcado NO es consentimiento. Tiene que ser una acción afirmativa explícita del titular de los datos. Si el checkbox viene marcado por default y el visitante lo deja así, ante una queja del INAI o el DPA europeo la prueba se cae: no hay registro de que el visitante eligió marcarlo. La consecuencia es multa por tratamiento de datos sin consentimiento. La frase decorativa «al enviar aceptas…» bajo el botón tampoco sustituye al checkbox; es informativa, no interactiva.
¿Cómo se compara con react-hook-form, Formik o el <Form> de Stripe?
react-hook-form (180 KB con peer deps) y Formik (95 KB) son librerías de state management para forms complejos con lógica condicional. Resuelven problemas reales —fields array dinámicos, validación async contra backend, persistencia en localStorage— pero introducen un runtime React que un form de contacto no necesita. Para un form de 5–7 campos sin lógica condicional, vanilla HTML5 + 60 líneas de JS es más rápido (3.8 KB vs 180+ KB), más accesible por default (las librerías a veces se enredan con <Controller> y rompen <label for>), y más fácil de auditar. El <Form> de Stripe (cuando lo usas para checkout) es harina de otro costal: maneja PCI compliance vía iframe seguro, no compite con un form de contacto. Si tu form crece a 15+ campos con ramas, salta a react-hook-form en un island Astro client:visible; debajo de ese umbral, vanilla gana en peso, accesibilidad y velocidad de desarrollo.
¿Honeypot con display: none o con clip CSS? ¿Por qué tanta diferencia?
Tres razones. Primera, bots básicos (botnets de spam masivo) parsean el DOM con regex barata y rellenan TODO input. Cualquier honeypot funciona contra ellos. Segunda, bots intermedios usan headless Chrome con executeScript('document.querySelectorAll("input")') y filtran los que tienen display: none o visibility: hidden —pierdes el 30% de tu defensa contra ellos con esa técnica—. Tercera, bots avanzados ejecutan el CSS completo y filtran cualquier campo con opacity: 0, fuera del viewport, o de tamaño 1×1; contra estos solo Turnstile/Friendly Captcha + rate-limit funcionan. El clip CSS (position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden) defiende contra los bots básicos Y los intermedios porque el campo SÍ es visible para el motor (existe, ocupa espacio en el render tree, no está display:none) pero ningún humano lo ve. Es el equilibrio óptimo entre simplicidad y eficacia.
¿Qué pasa con autocompletar y los gestores de contraseñas en formularios sin password?
1Password, Bitwarden y el llavero de Apple guardan más que credenciales: guardan «identities» (nombre, email, teléfono, dirección, RFC en algunos casos). Cuando el form tiene autocomplete="name", autocomplete="tel-national", autocomplete="email" correctos, estos gestores ofrecen autofill con la identidad guardada y el usuario rellena 5 campos con 1 click. Sin los autocomplete tokens, el gestor no detecta el campo y el usuario teclea todo a mano —fricción que mata conversión, sobre todo en móvil—. El error típico es poner autocomplete="off" por «privacidad» o por «seguridad»; en realidad es un anti-patrón que rompe a11y (los usuarios con discapacidad motriz dependen del autofill) y reduce conversión sin ningún beneficio real. Solo autocomplete="off" justificado: campos one-time (OTP, password nuevo, 2FA). Para nombre, email y teléfono, siempre tokens semánticos.
El formulario accesible no es un componente, es una disciplina —cada decisión de label, de pattern, de focus management, de antispam paga dividendos por años—. La trampa frecuente es resolverlo con una librería de un viernes y heredar 14 dependencias que se rompen en cada major update. El componente que esta guía construye vive en menos de 200 líneas de Astro + CSS + JS sin frameworks, cumple WCAG 2.2 AA por default y deja al sitio listo para escalar a backend serverless cuando crezca —el siguiente artículo del par cubre exactamente ese salto—.