guias

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.

Contact form Astro: WCAG, honeypot y es-MX

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-spamFricción al humanoEficaciaPrivacidad
Honeypot (input oculto)Cero (invisible)85–90% del spam masivoCero datos a terceros
Cloudflare TurnstileMí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)Cero92–96%Traza al usuario en TODO el sitio + score opaco
hCaptchaMedia (puzzle obligatorio)90–95%Sin tracking de Google, paga al sitio
Rate-limit por IPCero (transparente)60–70% (no detiene rotación)Cero
Validación server-side de shapeCero40–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

SCNivelAplicación al formImplementación
1.3.1 Info & RelationshipsACada input asociado a su label<label for="cf-tel"> + <input id="cf-tel">
1.3.5 Identify Input PurposeAAPropósito declarado vía autocomplete tokensautocomplete="name|tel|email|tel-national|postal-code"
1.4.3 Contrast (Minimum)AATexto del form contra fondo ≥ 4.5:1Token --c-text sobre --c-surface mide 7.1:1
1.4.11 Non-text ContrastAABordes de input contra fondo ≥ 3:1Borde #cbd5e1 sobre blanco mide 3.4:1
2.4.7 Focus VisibleAAOutline real, no solo cambio de color:focus-visible { outline: 2px solid var(--c-primary); offset: 2px }
2.5.5 Target Size (Enhanced)AAAControles ≥ 44×44 pxSubmit 48px, checkbox con padding extendido
2.5.8 Target Size (Minimum)AAControles ≥ 24×24 pxCumplido por default; el AAA es bonus
3.3.1 Error IdentificationAErrores anunciados al usuariorole="alert" + aria-invalid="true" en el campo
3.3.3 Error SuggestionAASugerencia de cómo corregir«Teléfono de 10 dígitos, sin lada internacional»
3.3.7 Redundant EntryANo repedir el mismo dato en un flujoSingle-step form: aplica trivialmente
4.1.3 Status MessagesAACambios de estado anunciados sin focus shift<div role="status" aria-live="polite"> post-submit

Tabla 3 · Patrones es-MX vs equivalentes US/EU

Datoes-MX patternUS equivalenteEU 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]
Estadodropdown 32 estados MXdropdown 50 estados USdropdown 27 países

Tabla 4 · Decision matrix · qué validas cliente vs server

ValidaciónCliente (HTML5/JS)Server (Pages Function)Razón de la doble
Required (presencia)Sí, required HTML5Sí, if (!nombre)Cliente para UX, server porque DevTools borra required
Longitud min/maxSí, minlength/maxlengthSí, if (n.length < 2)Igual; el atacante manda 10MB de mensaje sin el check
Shape de emailSí, type="email" + patternSí, regex equivalenteCliente avisa al humano, server protege del bot
Shape de teléfono MXSí, pattern="[0-9]{10}"Sí, mismo regexEl bot manda «cualquier-string»; sin server, lo aceptas
Checkbox consentSí, requiredSí, if (!consent)Sin el server check, el lead sin consentimiento te vuelve no-cumplimiento
Honeypot vacíoNo aplica (es invisible)Sí, if (data.get('website'))El honeypot vive en cliente pero se chequea en server
Turnstile válidoWidget renderiza soloSí, POST a siteverifyEl token es opaco; sin verificar puedes recibir tokens reciclados
Rate limit por IPNo (cliente no ve otras IPs)Sí, KV con TTLDefensa que solo existe server-side
Sanitización XSSNo (innecesaria si no muestras input back)Sí, escapar antes de logging/emailAunque 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:

EjeMétricaValorNotas
PerformanceHTML del componente4.8 KB (gzipped 1.9 KB)Sin frameworks, sin librerías
CSS scoped3.1 KB (gzipped 1.2 KB)Tokens compartidos con el resto del sitio
JS de validación1.4 KB (gzipped 0.7 KB)Vanilla, sin polyfills
LCP contribution0 ms (no es el LCP)El hero o título de la página manda
CLS contribution0Los <span class="cform__err"> reservan espacio con min-height
INP submit click38 ms (mediana, 50 envíos)Validación HTML5 + handler vanilla
TBT del bundle2 msUna iteración sobre forms en DOMContentLoaded
A11yWCAG 1.3.1 Labels100% (axe 0 violations)Cada input tiene <label for> literal
WCAG 1.3.5 Input Purpose100% (axe 0 violations)autocomplete en nombre, tel, email
WCAG 1.4.3 Contrast7.1:1 (texto) / 3.4:1 (borde input)AAA en texto, AA en non-text
WCAG 2.4.7 Focus Visible100%Outline 2px solid + offset 2px en cada control
WCAG 2.5.5 Target Size48px (submit) / 44px (checkbox)AAA cumplido en interactivos
WCAG 3.3.1 Error Identification100%role="alert" + aria-invalid="true"
WCAG 3.3.3 Error Suggestion100%Mensajes específicos por tipo de error
WCAG 4.1.3 Status Messages100%role="status" aria-live="polite" con limpieza pre-paint
NVDA testOK (recorrido completo del form en 38 s)Tab forward + tab back + submit + leer status
VoiceOver iOS testOK (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, textarea y select tiene label asociado con for/id literal
  • Inputs con font-size: 16px literal (no 1rem) para evitar zoom de iOS
  • Honeypot clipeado con position: absolute; left: -9999px, NO con display: none
  • Tipos HTML5 correctos: email, tel, url con inputmode y autocomplete match
  • Atributo pattern para 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" con aria-live="polite" para feedback post-submit
  • Focus management: foco al primer error si falla, al status si éxito
  • prefers-reduced-motion respetado 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—.

Sigue leyendo

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?