Formularios Avanzados
HTML5 trajo un conjunto de elementos y atributos que van mucho más allá del <input type="text">. Aprendé a crear formularios más ricos, accesibles y con mejor experiencia de usuario usando solo HTML.
datalist — autocompletado con lista de sugerencias
El elemento <datalist> combina lo mejor de un
<input> libre y un <select>:
el usuario puede escribir lo que quiera, pero mientras escribe aparece
una lista de sugerencias predefinidas.
La conexión entre el input y el datalist se hace a través del atributo
list del input, que debe coincidir con el id
del datalist. Las opciones dentro del datalist son solo sugerencias —
el usuario no está obligado a elegir una de ellas.
<label for="ciudad">Ciudad:</label>
<input type="text" id="ciudad" name="ciudad" list="ciudades"
placeholder="Escribí o elegí una ciudad">
<datalist id="ciudades">
<option value="Buenos Aires">
<option value="Córdoba">
<option value="Rosario">
<option value="Mendoza">
<option value="La Plata">
<option value="Mar del Plata">
</datalist>
También funciona con otros tipos de input, no solo text.
Con type="color" sugiere colores predefinidos; con
type="range" muestra marcas en el slider:
<!-- Paleta de colores con opciones predefinidas -->
<input type="color" list="paleta">
<datalist id="paleta">
<option value="#E34F26">
<option value="#264DE4">
<option value="#F7DF1E">
</datalist>
<!-- Slider con marcas en valores específicos -->
<input type="range" min="0" max="100" list="marcas">
<datalist id="marcas">
<option value="0">
<option value="25">
<option value="50">
<option value="75">
<option value="100">
</datalist>
Usá <select> cuando el usuario debe elegir una de las opciones disponibles. Usá <datalist> cuando las opciones son sugerencias pero el usuario puede ingresar cualquier otro valor.
output — mostrar el resultado de un cálculo
El elemento <output> está diseñado para mostrar el
resultado de un cálculo o acción del formulario. Semánticamente le indica
al navegador y a los lectores de pantalla que ese valor es generado dinámicamente
a partir de otros campos.
El atributo for conecta el output con los ids de los inputs
que lo generan, igual que un <label> se conecta con su campo.
<label for="precio">Precio base: $</label>
<input type="number" id="precio" name="precio" value="1000"
oninput="calcular()">
<label for="desc">Descuento: %</label>
<input type="range" id="desc" name="desc" min="0" max="50" value="0"
oninput="calcular()">
<p>
Precio final:
<output for="precio desc" id="resultado">$1000</output>
</p>
<script>
function calcular() {
const precio = parseFloat(document.getElementById("precio").value) || 0;
const desc = parseFloat(document.getElementById("desc").value) || 0;
const final = precio * (1 - desc / 100);
document.getElementById("resultado").value = "$" + final.toFixed(2);
}
</script>
meter — indicador de valor en un rango conocido
El elemento <meter> representa un valor escalar dentro
de un rango conocido. Es semánticamente correcto para mostrar métricas
como uso de disco, nivel de batería, puntuación de un producto o
compatibilidad de un candidato para un puesto.
Lo que hace <meter> diferente de una simple barra de CSS
es que entiende conceptos como valores bajos, óptimos y altos, y el navegador
puede cambiar el color de la barra automáticamente según si el valor está
en zona buena, media o mala.
<!-- Atributos del meter -->
<!--
value: valor actual
min: mínimo posible (por defecto: 0)
max: máximo posible (por defecto: 1)
low: umbral de "valor bajo"
high: umbral de "valor alto"
optimum: dónde está el valor óptimo (afecta el color)
-->
<!-- Uso de disco — cuanto más alto, peor -->
<label>Uso de disco:</label>
<meter value="75" min="0" max="100" low="50" high="80" optimum="0">
75%
</meter>
<span>75%</span>
<!-- Puntaje de reseña — cuanto más alto, mejor -->
<label>Calificación:</label>
<meter value="4.2" min="0" max="5" low="2" high="3.5" optimum="5">
4.2 de 5
</meter>
<span>4.2 / 5</span>
<!-- Batería -->
<label>Batería:</label>
<meter value="0.2" min="0" max="1" low="0.25" high="0.5" optimum="1">
20%
</meter>
<span>20%</span>
El navegador colorea la barra en verde (zona óptima), amarillo (zona media) o rojo (zona problemática) según la relación entre value, low, high y optimum. Si optimum está en el extremo alto y el value está por debajo de low, la barra se pone roja.
progress — barra de progreso
El elemento <progress> muestra el avance de una tarea
que tiene un inicio y un fin. Es semánticamente diferente de
<meter>: no tiene zonas buenas o malas, solo
indica cuánto falta para completar algo.
Si omitís el atributo value, el progress se muestra en estado
indeterminado (animación de espera), útil cuando no sabés cuánto
falta para que termine un proceso.
<!-- Progreso determinado: 60% completado -->
<label>Cargando perfil:</label>
<progress value="60" max="100">60%</progress>
<!-- Progreso indeterminado: sin value, muestra animación -->
<label>Procesando pago...</label>
<progress>Procesando...</progress>
<!-- Ejemplo dinámico con JS -->
<progress id="barra" value="0" max="100"></progress>
<button onclick="avanzar()">Avanzar</button>
<script>
function avanzar() {
const barra = document.getElementById("barra");
if (barra.value < 100) {
barra.value += 10;
}
}
</script>
| Elemento | Para qué sirve | Tiene zonas buenas/malas |
|---|---|---|
<progress> | Avance hacia completar una tarea | No |
<meter> | Valor en un rango con zonas semánticas | Sí |
Select avanzado — optgroup y selección múltiple
El elemento <select> tiene dos funcionalidades menos conocidas
pero muy útiles: agrupar opciones con <optgroup> y
permitir la selección de varios elementos a la vez con el atributo multiple.
optgroup — agrupar opciones
Cuando un select tiene muchas opciones, agruparlas con <optgroup>
mejora la legibilidad. El atributo label del optgroup se muestra
como un encabezado no seleccionable dentro del dropdown.
<label for="pais">País:</label>
<select id="pais" name="pais">
<option value="">— Seleccioná un país —</option>
<optgroup label="América del Sur">
<option value="AR">Argentina</option>
<option value="BR">Brasil</option>
<option value="CL">Chile</option>
<option value="UY">Uruguay</option>
</optgroup>
<optgroup label="América Central">
<option value="MX">México</option>
<option value="CR">Costa Rica</option>
</optgroup>
<optgroup label="Europa">
<option value="ES">España</option>
<option value="FR">Francia</option>
</optgroup>
</select>
multiple — selección múltiple
Con el atributo multiple, el select se transforma en una lista
donde el usuario puede seleccionar varios elementos manteniendo presionado
Ctrl (Windows) o Cmd (Mac). El atributo size
controla cuántas opciones se ven a la vez.
<label for="habilidades">
Habilidades (Ctrl + clic para seleccionar varias):
</label>
<select id="habilidades" name="habilidades[]" multiple size="5">
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="js">JavaScript</option>
<option value="php">PHP</option>
<option value="sql">SQL</option>
<option value="git">Git</option>
</select>
El select con multiple nativo tiene una usabilidad bastante mala en mobile y no es intuitivo en desktop (muchos usuarios no saben que existe el Ctrl+clic). Para producción, generalmente se reemplaza con checkboxes visibles o un componente JS. Igual es importante conocerlo.
Validación personalizada con setCustomValidity()
La validación nativa de HTML5 (atributos como required,
minlength, pattern) cubre muchos casos,
pero a veces necesitás lógica más compleja: verificar que dos contraseñas
coincidan, validar un campo según el valor de otro, o mostrar un
mensaje de error personalizado.
El método setCustomValidity() de JavaScript te permite
integrar esa lógica personalizada con el sistema de validación nativo
del navegador. Si pasás un string no vacío, el campo se marca como inválido
con ese mensaje; si pasás un string vacío, se marca como válido.
<form id="miForm" novalidate>
<label for="pass1">Contraseña:</label>
<input type="password" id="pass1" name="pass1" required minlength="8">
<label for="pass2">Repetir contraseña:</label>
<input type="password" id="pass2" name="pass2" required>
<button type="submit">Registrarse</button>
</form>
<script>
const pass1 = document.getElementById("pass1");
const pass2 = document.getElementById("pass2");
const form = document.getElementById("miForm");
// Validar mientras escribe en el segundo campo
pass2.addEventListener("input", () => {
if (pass1.value !== pass2.value) {
pass2.setCustomValidity("Las contraseñas no coinciden.");
} else {
pass2.setCustomValidity(""); // vacío = válido
}
});
// Al enviar el formulario, verificar todo
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!form.checkValidity()) {
form.reportValidity(); // muestra los mensajes de error nativos
return;
}
console.log("Formulario válido — enviando...");
});
</script>
Mensajes de error personalizados con :invalid
Además de setCustomValidity(), podés usar la pseudo-clase CSS
:invalid para estilizar campos inválidos y la pseudo-clase
:valid para los válidos. Una técnica común es no mostrar los estilos
de error hasta que el usuario haya interactuado con el campo:
<style>
/* Solo muestra error después de que el usuario tocó el campo */
input:not(:placeholder-shown):invalid {
border-color: #dc2626;
background-color: #fef2f2;
}
input:not(:placeholder-shown):valid {
border-color: #059669;
background-color: #f0fdf4;
}
/* El mensaje de error solo aparece en campos inválidos tocados */
input:not(:placeholder-shown):invalid + .error-msg {
display: block;
}
.error-msg {
display: none;
color: #dc2626;
font-size: 12px;
margin-top: 4px;
}
</style>
<div class="campo">
<label for="email">Email:</label>
<input type="email" id="email" name="email"
placeholder="usuario@ejemplo.com" required>
<span class="error-msg">Ingresá un email válido.</span>
</div>
El atributo novalidate en el <form> desactiva la validación automática del navegador al enviar. Esto es útil cuando querés manejar toda la validación con JavaScript para tener control total sobre cuándo y cómo se muestran los errores, manteniendo igual la API de checkValidity() y setCustomValidity().
Radio y checkbox accesibles
Los grupos de radio y checkbox requieren una estructura particular para ser correctamente accesibles. El error más común es no agruparlos semánticamente, lo que hace que los lectores de pantalla no puedan entender la relación entre los botones y la pregunta que responden.
La forma correcta es usar <fieldset> con <legend>
para agrupar cada conjunto de opciones relacionadas. El <legend>
actúa como la pregunta o etiqueta del grupo entero.
<!-- ❌ Mal — sin fieldset, los lectores de pantalla no entienden el grupo -->
<p>Método de pago:</p>
<input type="radio" id="tarjeta" name="pago" value="tarjeta">
<label for="tarjeta">Tarjeta de crédito</label>
<input type="radio" id="transferencia" name="pago" value="transferencia">
<label for="transferencia">Transferencia bancaria</label>
<!-- ✅ Bien — fieldset y legend agrupan semánticamente -->
<fieldset>
<legend>Método de pago</legend>
<div>
<input type="radio" id="tarjeta" name="pago" value="tarjeta">
<label for="tarjeta">Tarjeta de crédito</label>
</div>
<div>
<input type="radio" id="transferencia" name="pago" value="transferencia">
<label for="transferencia">Transferencia bancaria</label>
</div>
<div>
<input type="radio" id="efectivo" name="pago" value="efectivo">
<label for="efectivo">Efectivo</label>
</div>
</fieldset>
Lo mismo aplica para grupos de checkboxes relacionados:
<fieldset>
<legend>Notificaciones que querés recibir</legend>
<div>
<input type="checkbox" id="notif-email" name="notif" value="email" checked>
<label for="notif-email">Por email</label>
</div>
<div>
<input type="checkbox" id="notif-sms" name="notif" value="sms">
<label for="notif-sms">Por SMS</label>
</div>
<div>
<input type="checkbox" id="notif-push" name="notif" value="push">
<label for="notif-push">Notificaciones push</label>
</div>
</fieldset>
Cada radio y cada checkbox necesita su propio <label> con el for correspondiente al id del input. Esto hace que el área de clic incluya el texto del label — mucho más fácil de usar en mobile que hacer clic sobre el cuadradito.
Patrón de formulario multipaso
Cuando un formulario tiene muchos campos, dividirlo en pasos mejora considerablemente la experiencia del usuario. La idea es mostrar solo una parte del formulario a la vez, avanzando con botones "Siguiente" y "Atrás".
Desde el punto de vista HTML, el formulario sigue siendo uno solo — con
un único <form> y un único <button type="submit">
al final. Los pasos intermedios son divs que se muestran u ocultan con CSS y JS.
De esta forma, cuando el usuario llega al submit, todos los campos ya completados
están dentro del formulario y se envían juntos.
<form id="registro" novalidate>
<!-- Indicador de progreso -->
<div role="progressbar" aria-valuenow="1" aria-valuemin="1"
aria-valuemax="3" aria-label="Paso 1 de 3">
<span>Paso <output id="paso-actual">1</output> de 3</span>
<progress id="barra-progreso" value="1" max="3"></progress>
</div>
<!-- Paso 1: Datos personales -->
<fieldset id="paso-1">
<legend>Datos personales</legend>
<label for="nombre">Nombre:</label>
<input type="text" id="nombre" name="nombre" required minlength="2">
<label for="apellido">Apellido:</label>
<input type="text" id="apellido" name="apellido" required>
<label for="nacimiento">Fecha de nacimiento:</label>
<input type="date" id="nacimiento" name="nacimiento" required>
</fieldset>
<!-- Paso 2: Contacto (oculto inicialmente) -->
<fieldset id="paso-2" hidden>
<legend>Información de contacto</legend>
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="tel">Teléfono:</label>
<input type="tel" id="tel" name="tel"
pattern="[0-9]{8,15}" placeholder="Ej: 3415551234">
</fieldset>
<!-- Paso 3: Cuenta (oculto inicialmente) -->
<fieldset id="paso-3" hidden>
<legend>Crear contraseña</legend>
<label for="pass">Contraseña:</label>
<input type="password" id="pass" name="pass"
required minlength="8"
aria-describedby="req-pass">
<small id="req-pass">Mínimo 8 caracteres.</small>
<label for="pass2">Repetir contraseña:</label>
<input type="password" id="pass2" name="pass2" required>
</fieldset>
<!-- Navegación entre pasos -->
<div class="nav-pasos">
<button type="button" id="btn-anterior" hidden>← Anterior</button>
<button type="button" id="btn-siguiente">Siguiente →</button>
<button type="submit" id="btn-enviar" hidden>Registrarse</button>
</div>
</form>
<script>
let pasoActual = 1;
const totalPasos = 3;
function mostrarPaso(n) {
// Ocultar todos los pasos
for (let i = 1; i <= totalPasos; i++) {
document.getElementById(`paso-${i}`).hidden = true;
}
// Mostrar el paso actual
document.getElementById(`paso-${n}`).hidden = false;
// Actualizar indicadores
document.getElementById("paso-actual").value = n;
document.getElementById("barra-progreso").value = n;
// Mostrar/ocultar botones según el paso
document.getElementById("btn-anterior").hidden = (n === 1);
document.getElementById("btn-siguiente").hidden = (n === totalPasos);
document.getElementById("btn-enviar").hidden = (n !== totalPasos);
}
document.getElementById("btn-siguiente").addEventListener("click", () => {
const fieldset = document.getElementById(`paso-${pasoActual}`);
const campos = fieldset.querySelectorAll("input, select, textarea");
let valido = true;
// Validar solo los campos del paso actual
campos.forEach(campo => {
if (!campo.checkValidity()) {
campo.reportValidity();
valido = false;
}
});
if (valido && pasoActual < totalPasos) {
pasoActual++;
mostrarPaso(pasoActual);
}
});
document.getElementById("btn-anterior").addEventListener("click", () => {
if (pasoActual > 1) {
pasoActual--;
mostrarPaso(pasoActual);
}
});
</script>
Al avanzar al siguiente paso, validamos solo los campos del paso actual llamando a checkValidity() campo por campo. Si validáramos el formulario entero con form.checkValidity(), los campos de pasos posteriores (que el usuario aún no completó) también fallarían.
Autocompletado del navegador — autocomplete
El atributo autocomplete va mucho más allá de on
y off. Con valores específicos le decís al navegador exactamente
qué tipo de dato contiene cada campo, permitiéndole rellenar formularios
automáticamente con los datos guardados del usuario — nombre, dirección,
tarjeta de crédito, etc.
Esto es especialmente valioso en formularios de pago y registro, donde el autocompletado correcto puede reducir el tiempo de llenado de minutos a segundos, especialmente en mobile.
<form autocomplete="on">
<!-- Datos personales -->
<input type="text" name="nombre" autocomplete="given-name">
<input type="text" name="apellido" autocomplete="family-name">
<input type="email" name="email" autocomplete="email">
<input type="tel" name="telefono" autocomplete="tel">
<!-- Dirección completa -->
<input type="text" name="direccion" autocomplete="street-address">
<input type="text" name="ciudad" autocomplete="address-level2">
<input type="text" name="provincia" autocomplete="address-level1">
<input type="text" name="cp" autocomplete="postal-code">
<input type="text" name="pais" autocomplete="country-name">
<!-- Contraseñas -->
<input type="password" name="pass" autocomplete="new-password">
<input type="password" name="pass-actual" autocomplete="current-password">
<!-- Tarjeta de crédito -->
<input type="text" name="cc-num" autocomplete="cc-number">
<input type="text" name="cc-nombre" autocomplete="cc-name">
<input type="month" name="cc-vence" autocomplete="cc-exp">
<input type="text" name="cc-cvv" autocomplete="cc-csc">
</form>
Usar autocomplete="new-password" en campos de nueva contraseña le indica al navegador que ofrezca generar y guardar una contraseña nueva. Usar current-password en campos de login hace que ofrezca las contraseñas ya guardadas. Confundirlos arruina la experiencia con los gestores de contraseñas.
Buenas Prácticas
- Usá datalist para sugerencias, select para obligar una opción: la diferencia semántica importa y afecta la UX
- output para resultados de cálculos: es más semántico que un
<span>o un<p>genérico - meter para métricas, progress para avance: aunque visualmente parecidos, tienen semántica diferente — usá el correcto según el contexto
- fieldset y legend siempre en grupos de radio o checkbox: sin esto los lectores de pantalla no pueden relacionar las opciones con la pregunta
- Un label por input sin excepciones: mejora la usabilidad (área de clic) y la accesibilidad
- setCustomValidity("") para limpiar errores: si lo seteás con un mensaje y después el usuario corrige el campo, tenés que llamarlo con string vacío para que el campo vuelva a estar válido
- autocomplete con valores semánticos: no solo
on/off— uságiven-name,email,new-password, etc. para que los gestores de contraseñas y el autocompletado del navegador funcionen correctamente - En formularios multipaso, validar solo el paso actual: validar todo el formulario antes de llegar al final rompe la experiencia del usuario
- Siempre validar en el servidor: toda la validación HTML y JS que vimos es para mejorar la UX, nunca para reemplazar la validación del backend