Formularios & Validación
Los formularios son el puente entre el usuario y tu aplicación: son cómo recolectas datos, registras usuarios, procesas búsquedas y permites interacción. Un buen formulario no solo funciona correctamente, sino que es fácil de usar, accesible y está bien validado. HTML5 trae herramientas poderosas de validación nativa que te ahorran escribir JavaScript para los casos más comunes.
Estructura de un formulario
Todo formulario empieza con la etiqueta <form>, que define el contenedor y los atributos de comportamiento: action indica a dónde se envían los datos, y method define cómo se envían. Dentro del form van los controles (inputs, selects, textareas) y sus etiquetas asociadas. El botón de envío (<button type="submit">) dispara el formulario.
El atributo method tiene dos valores principales: GET envía los datos en la URL (visible en la barra de direcciones, ideal para búsquedas y filtros) y POST los envía en el cuerpo del request (invisible, ideal para datos sensibles, formularios de login, cargas de archivos). Si omitis method, el default es GET, lo que es peligroso para datos privados.
<!-- Estructura basica de un formulario -->
<form action="/procesar" method="POST">
<!-- Controles del formulario aqui -->
<button type="submit">Enviar</button>
</form>
<!-- Formulario de busqueda (usa GET) -->
<form action="/buscar" method="GET">
<input type="search" name="q" placeholder="Buscar...">
<button type="submit">Buscar</button>
</form>
No uses GET para datos sensibles
Con method="GET", los datos del formulario aparecen en la URL como parámetros de query. Esto significa que quedan visibles en la barra de direcciones, en el historial del navegador, en los logs del servidor y se pueden compartir accidentalmente al copiar el enlace. Para passwords, datos personales o cualquier información sensible, usa siempre method="POST".
<label>, <fieldset> y accesibilidad
La etiqueta <label> es probablemente la más importante para la accesibilidad de formularios. Asocia un texto descriptivo con un control específico mediante el atributo for (que debe coincidir con el id del input). Cuando un usuario hace click en el label, el foco se mueve automáticamente al input asociado. Esto no solo es útil para usuarios de mouse, sino que es crítico para lectores de pantalla que leen el texto del label como la descripción del campo.
<fieldset> agrupa controles relacionados y <legend> le da un título al grupo. Es especialmente útil para formularios largos con múltiples secciones (datos personales, dirección, preferencias), para grupos de radio buttons, y para checkbox groups. Los lectores de pantalla anuncian el legend como el título del grupo, lo que da contexto a cada opción.
<!-- Label con for/id (forma correcta) -->
<label for="nombre">Nombre completo</label>
<input type="text" id="nombre" name="nombre">
<!-- Label envolviendo el input (alternativa valida) -->
<label>
Email
<input type="email" name="email">
</label>
<!-- Fieldset con legend para agrupar -->
<fieldset>
<legend>Datos personales</legend>
<label for="nombre">Nombre</label>
<input type="text" id="nombre" name="nombre" required>
<label for="apellido">Apellido</label>
<input type="text" id="apellido" name="apellido" required>
</fieldset>
<!-- Radio buttons con fieldset -->
<fieldset>
<legend>Nivel de experiencia</legend>
<label><input type="radio" name="nivel" value="principiante"> Principiante</label>
<label><input type="radio" name="nivel" value="intermedio"> Intermedio</label>
<label><input type="radio" name="nivel" value="avanzado"> Avanzado</label>
</fieldset>
Nunca uses placeholder como label
El atributo placeholder desaparece cuando el usuario empieza a escribir, dejando al usuario sin referencia de qué se esperaba en ese campo. Los lectores de pantalla no lo anuncian de forma confiable. Siempre usa <label> con for. El placeholder es un complemento opcional para dar un ejemplo del formato esperado, nunca un reemplazo del label.
Tipos de input
HTML5 introdujo una gran variedad de tipos de input que van mucho más allá de text y password. Cada tipo activa controles nativos del navegador adaptados al tipo de dato: un teclado numérico en móvil para tel y number, un selector de fecha para date, un color picker para color, y validación automática para email y url. Usar el tipo correcto mejora la experiencia del usuario, especialmente en dispositivos móviles donde el teclado cambiado puede hacer una gran diferencia.
| Tipo | Uso | Validación automática |
|---|---|---|
text |
Texto genérico (default) | Ninguna |
email |
Direcciones de email | Requiere formato de email |
password |
Contraseñas (oculto) | Ninguna |
number |
Números (spinner) | Solo números |
tel |
Teléfonos | Ninguna (teclado numérico en móvil) |
url |
URLs / enlaces | Requiere formato de URL |
date |
Fecha (selector nativo) | Formato de fecha válido |
time |
Hora (selector nativo) | Formato de hora válido |
datetime-local |
Fecha y hora juntos | Formato combinado válido |
month |
Selector de mes y año | Formato de mes válido |
week |
Selector de semana | Formato de semana válido |
range |
Slider (valor entre min y max) | Número dentro del rango |
color |
Selector de color | Formato hexadecimal (#RRGGBB) |
search |
Campos de búsqueda (con X para limpiar) | Ninguna |
file |
Subida de archivos | Ninguna |
checkbox |
Selección múltiple (toggle) | Ninguna |
radio |
Selección única (grupo) | Ninguna |
hidden |
Datos invisibles para el usuario | Ninguna |
<!-- Ejemplos de tipos de input -->
<!-- Email con validacion nativa -->
<label for="email">Email</label>
<input type="email" id="email" name="email"
placeholder="tu@email.com" required>
<!-- Telefono (teclado numerico en mobile) -->
<label for="telefono">Teléfono</label>
<input type="tel" id="telefono" name="telefono"
placeholder="+54 11 1234-5678">
<!-- Fecha de nacimiento -->
<label for="nacimiento">Fecha de nacimiento</label>
<input type="date" id="nacimiento" name="nacimiento"
min="1900-01-01" max="2026-06-10">
<!-- Slider de volumen -->
<label for="volumen">Volumen: <output id="vol-output">50</output></label>
<input type="range" id="volumen" name="volumen"
min="0" max="100" value="50">
<!-- Selector de color -->
<label for="color-fav">Color favorito</label>
<input type="color" id="color-fav" name="color" value="#58a6ff">
<!-- Subir archivo -->
<label for="avatar">Avatar</label>
<input type="file" id="avatar" name="avatar"
accept="image/png, image/jpeg, image/webp">
<!-- Checkbox -->
<label>
<input type="checkbox" name="newsletter" value="si">
Quiero recibir el newsletter
</label>
El atributo accept en file inputs
El atributo accept en <input type="file"> filtra qué tipos de archivos puede seleccionar el usuario. Acepta MIME types (image/png), extensiones (.pdf), o comodines (image/*). Es un filtro del lado del cliente (se puede saltar), así que siempre valida también en el servidor.
text
email
search
number
range
color
date
checkbox
radio
<textarea> y <select>
<textarea> es para textos largos de varias líneas (mensajes, comentarios, descripciones). A diferencia de <input>, tiene una etiqueta de apertura y cierre, y el contenido entre ambas es el valor inicial. Los atributos rows y cols definen el tamaño inicial, pero lo más común es usar CSS para controlarlo. Con maxlength limitas la cantidad de caracteres, y con minlength estableces un mínimo (validación al enviar).
<select> crea un menú desplegable con opciones predefinidas. Cada opción va dentro de <option>, y podés agruparlas con <optgroup>. El atributo multiple permite selección múltiple (con Ctrl/Cmd). El <datalist> es una alternativa interesante: en lugar de un dropdown cerrado, ofrece sugerencias mientras el usuario escribe, como un autocompletado nativo.
<!-- Textarea para comentarios -->
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows="5"
maxlength="500" placeholder="Escribi tu mensaje...">
</textarea>
<small>Máximo 500 caracteres</small>
<!-- Select basico -->
<label for="pais">País</label>
<select id="pais" name="pais" required>
<option value="">Seleccioná un país</option>
<optgroup label="América">
<option value="ar">Argentina</option>
<option value="mx">México</option>
<option value="co">Colombia</option>
<option value="cl">Chile</option>
</optgroup>
<optgroup label="Europa">
<option value="es">España</option>
<option value="it">Italia</option>
</optgroup>
</select>
<!-- Select multiple -->
<label for="skills">Habilidades (Ctrl + click para varias)</label>
<select id="skills" name="skills" multiple size="5">
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="js">JavaScript</option>
<option value="react">React</option>
<option value="node">Node.js</option>
</select>
<!-- Datalist: autocompletado nativo -->
<label for="framework">Framework favorito</label>
<input type="text" id="framework" name="framework"
list="frameworks-list">
<datalist id="frameworks-list">
<option value="React">
<option value="Vue">
<option value="Angular">
<option value="Svelte">
<option value="Astro">
</datalist>
datalist vs select
La diferencia clave es la flexibilidad: <select> obliga al usuario a elegir una de las opciones predefinidas. <datalist> muestra sugerencias pero permite escribir un valor custom. Es ideal para campos donde querés guiar al usuario sin restringirlo, como "ciudad" o "cargo". La combinación input + datalist crea un campo con autocompletado nativo sin JavaScript.
select
textarea
datalist
fieldset
Validación nativa de HTML5
HTML5 trae validación incorporada que funciona sin JavaScript. El navegador verifica los campos antes de enviar el formulario y muestra mensajes de error contextuales. Esto cubre los casos más comunes y es perfectamente suficiente para muchos formularios. La validación se activa al hacer click en el botón submit (o al llamar a form.reportValidity()), no mientras el usuario escribe (a menos que uses CSS :invalid para feedback en tiempo real).
Atributos de validación
Los atributos de validación son booleanos (se activan con solo estar presentes) o toman un valor específico. Combinarlos te permite crear reglas bastante sofisticadas sin una línea de JavaScript. required es el más básico: el campo no puede estar vacío. minlength y maxlength controlan la longitud del texto. min, max y step funcionan con números y fechas. pattern acepta una regex para validación personalizada de texto.
<!-- required: campo obligatorio -->
<label for="nombre">Nombre *</label>
<input type="text" id="nombre" name="nombre" required>
<!-- minlength / maxlength -->
<label for="usuario">Usuario</label>
<input type="text" id="usuario" name="usuario"
minlength="3" maxlength="20"
placeholder="3 a 20 caracteres">
<!-- min / max / step (numeros) -->
<label for="edad">Edad</label>
<input type="number" id="edad" name="edad"
min="18" max="120" step="1">
<!-- min / max (fechas) -->
<label for="evento">Fecha del evento</label>
<input type="date" id="evento" name="evento"
min="2026-06-10">
<!-- pattern: regex personalizada -->
<label for="codigo-postal">Código Postal</label>
<input type="text" id="codigo-postal" name="codigo-postal"
pattern="[0-9]{4,5}"
placeholder="Ej: 1234 o B7400">
<!-- pattern para password fuerte -->
<label for="clave">Contraseña</label>
<input type="password" id="clave" name="clave"
minlength="8"
pattern="(?=.*[A-Z])(?=.*\d).{8,}"
title="Mínimo 8 caracteres, una mayúscula y un número">
Pseudo-clases CSS para validación
CSS tiene pseudo-clases que se activan según el estado de validación del input, lo que te permite dar feedback visual en tiempo real sin JavaScript. :valid y :invalid se aplican según si el campo cumple las reglas HTML. :required y :optional identifican campos obligatorios. :focus-visible muestra el foco solo con teclado. :placeholder-shown detecta si el placeholder está visible (campo vacío). Combinar estas pseudo-clases te da control total sobre el aspecto visual de cada estado del input.
/* Feedback visual con pseudo-clases */
/* Solo mostrar invalid DESPUES de que el usuario interactuo */
input:not(:placeholder-shown):invalid {
border-color: var(--accent-red);
}
input:not(:placeholder-shown):valid {
border-color: var(--accent-green);
}
/* Campos requeridos */
input:required {
border-left: 3px solid var(--accent-blue);
}
/* Foco con teclado */
input:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}
novalidate y validación custom
Si necesitas validación 100% custom con JavaScript, agregá novalidate al <form> para desactivar la validación nativa. Luego usás los métodos del API: element.checkValidity(), element.validity.valid, element.validity.valueMissing, y element.setCustomValidity("mensaje") para mensajes personalizados. Así controlas todo el proceso sin que el navegador intervenga con sus mensajes por defecto.
Atributos de accesibilidad en formularios
Un formulario accesible no solo esusable por personas con discapacidades, sino que mejora la experiencia para todos. Los lectores de pantalla dependen de la estructura semántica del formulario para anunciar correctamente cada campo, y la navegación por teclado permite a cualquier usuario moverse eficientemente entre campos. Estos atributos y prácticas no agregan complejidad al código pero marcan una diferencia enorme.
<!-- aria-describedby: vincula con mensaje de ayuda -->
<label for="password">Contraseña</label>
<input type="password" id="password" name="password"
aria-describedby="password-help">
<small id="password-help">
Mínimo 8 caracteres con al menos una mayúscula.
</small>
<!-- aria-required vs required (uso ambos) -->
<label for="email">Email *</label>
<input type="email" id="email" name="email"
required aria-required="true">
<!-- tabindex: orden de navegacion con teclado -->
<input type="text" name="nombre" tabindex="1">
<input type="email" name="email" tabindex="2">
<input type="password" name="password" tabindex="3">
<!-- autocomplete: ayuda al usuario y al navegador -->
<input type="text" name="nombre" autocomplete="name">
<input type="email" name="email" autocomplete="email">
<input type="tel" name="telefono" autocomplete="tel">
<input type="text" name="direccion" autocomplete="street-address">
<input type="password" name="clave-nueva"
autocomplete="new-password">
<!-- disabled vs readonly -->
<input type="text" value="No se puede editar" disabled>
<input type="text" value="Se puede leer pero no editar" readonly>
| Atributo | Propósito | Cuándo usarlo |
|---|---|---|
aria-describedby |
Vincula con texto de ayuda | Siempre que un campo tenga instrucciones adicionales |
aria-required |
Indica campo obligatorio | Para lectores de pantalla (complementa required) |
aria-invalid |
Indica error activo | Validación custom JS (no con validación nativa) |
autocomplete |
Ayuda al autocompletado del browser | En todos los campos de datos personales |
autofocus |
Foco automático al cargar | Solo en el primer campo de un form (uno solo por página) |
disabled |
Deshabilita totalmente el campo | Campo no editable y no se envía con el form |
readonly |
Solo lectura (se envía) | Datos que se muestran pero no se pueden editar |
Un solo autofocus por página
Usa autofocus en un solo campo por página, idealmente el primer input del formulario principal. Múltiples autofocus confunden al usuario (y al lector de pantalla) porque compiten por el foco. En móvil, el autofocus puede abrir el teclado automáticamente, lo que molesta si no es lo que el usuario espera. Considerá omitirlo en móvil con CSS o media queries.
Ejemplo: formulario de registro
Para cerrar, acá tenés un formulario de registro completo que usa todo lo que vimos: labels con for, fieldsets con legend, validación nativa, atributos de accesibilidad, tipos de input correctos, y una estructura semántica limpia. Es un ejemplo que podés usar como plantilla para tus propios formularios, adaptando los campos y validaciones a tus necesidades.
<form action="/registro" method="POST" novalidate>
<!-- Datos personales -->
<fieldset>
<legend>Datos personales</legend>
<div>
<label for="nombre">Nombre completo *</label>
<input type="text" id="nombre" name="nombre"
required minlength="2" maxlength="100"
autocomplete="name"
aria-required="true">
</div>
<div>
<label for="email">Email *</label>
<input type="email" id="email" name="email"
required
autocomplete="email"
aria-required="true"
aria-describedby="email-help">
<small id="email-help">
No compartiremos tu email con nadie.
</small>
</div>
<div>
<label for="password">Contraseña *</label>
<input type="password" id="password" name="password"
required minlength="8"
pattern="(?=.*[A-Z])(?=.*\d).{8,}"
title="Mínimo 8 caracteres, una mayúscula y un número"
autocomplete="new-password"
aria-required="true"
aria-describedby="pass-help">
<small id="pass-help">
Mínimo 8 caracteres, al menos una mayúscula y un número.
</small>
</div>
</fieldset>
<!-- Preferencias -->
<fieldset>
<legend>Preferencias</legend>
<div>
<label for="pais">País</label>
<select id="pais" name="pais">
<option value="">Seleccioná un país</option>
<option value="ar">Argentina</option>
<option value="mx">México</option>
<option value="es">España</option>
</select>
</div>
<div>
<label for="fecha-nac">Fecha de nacimiento</label>
<input type="date" id="fecha-nac" name="fecha_nac"
min="1920-01-01" max="2010-12-31">
</div>
<div>
<label>
<input type="checkbox" name="newsletter" value="si">
Quiero recibir el newsletter semanal
</label>
</div>
</fieldset>
<!-- Bio -->
<div>
<label for="bio">Sobre vos</label>
<textarea id="bio" name="bio" rows="4"
maxlength="500"
placeholder="Contános algo sobre vos..."></textarea>
</div>
<!-- Botones -->
<div>
<button type="submit">Crear cuenta</button>
<button type="reset">Limpiar</button>
</div>
</form>
Tipos de botón en formularios
El atributo type del <button> define su comportamiento: submit envía el formulario (es el default), reset limpia todos los campos a sus valores iniciales, y button no hace nada por sí solo (ideal para botones que activan JavaScript). Siempre especificá el type explícitamente para evitar sorpresas, especialmente si el botón está dentro de un <form>.