Módulo 05: Formularios
Objetivos del módulo
- Entender cómo viajan los datos de un formulario desde el navegador hasta el servidor
- Configurar el elemento
formconactionymethod, y saber elegir entre GET y POST - Asociar correctamente cada control con su
label - Conocer todos los tipos de
inputde HTML5 y cuándo usar cada uno - Usar
textarea,select,option,optgroupydatalist - Distinguir los tipos de botón:
submit,resetybutton - Agrupar controles relacionados con
fieldsetylegend - Dominar los atributos de los controles:
name,value,placeholder,required,min,max,step,pattern,minlength,maxlength,autocomplete,disabled,readonly,autofocus… - Aprovechar la validación nativa del navegador y las pseudoclases
:valide:invalid - Crear formularios accesibles para todas las personas
- Construir un formulario de registro y uno de contacto completos
1. ¿Qué es un formulario?
📘 Concepto: Un formulario es la forma que tiene el usuario de enviar información a un sitio web: buscar en Google, iniciar sesión, comprar, dejar un comentario, reservar mesa… Está formado por controles (campos de texto, casillas, listas desplegables, botones) agrupados dentro de un elemento
<form>.
Piensa en un formulario web como en un impreso en papel de la administración: tiene casillas etiquetadas (“Nombre”, “DNI”, “Fecha de nacimiento”), unas obligatorias y otras no, y al final un sitio donde entregarlo (la ventanilla). En la web, la ventanilla es la URL del servidor que procesa los datos.
El viaje de los datos
sequenceDiagram
participant U as Usuario
participant N as Navegador
participant S as Servidor
U->>N: Rellena los campos y pulsa Enviar
N->>N: Validación nativa (required, type, pattern...)
alt Hay errores
N-->>U: Muestra el mensaje de error y no envía
else Todo correcto
N->>S: Petición GET o POST con los pares nombre=valor
S->>S: Valida de nuevo y procesa (guarda, envía email...)
S-->>N: Respuesta (página de confirmación o de error)
N-->>U: Muestra la respuesta
end
⚠️ Importante: HTML solo se encarga de la parte del navegador: mostrar los controles, validar en primera instancia y enviar los datos. Procesarlos (guardarlos en una base de datos, enviar un correo) es trabajo de un programa en el servidor (PHP, Python, Node.js…) o de un servicio externo. En este módulo usaremos un servicio de pruebas para ver qué datos se envían.
2. El elemento form
<form action="/buscar" method="get">
<label for="consulta">Buscar en la web</label>
<input type="search" id="consulta" name="q">
<button type="submit">Buscar</button>
</form>
Atributos principales
| Atributo | Significado | Valores típicos |
|---|---|---|
action | URL a la que se envían los datos. Si se omite, se envían a la misma página | /registro, https://api.ejemplo.es/contacto |
method | Método HTTP del envío | get (por defecto) o post |
enctype | Cómo se codifican los datos (solo con post) | application/x-www-form-urlencoded (por defecto), multipart/form-data (obligatorio para subir ficheros) |
autocomplete | Activa o desactiva el autocompletado de todo el formulario | on (por defecto), off |
novalidate | Desactiva la validación nativa del navegador | Atributo booleano |
Cómo se envían los datos: pares nombre=valor
📘 Concepto: Al enviar, el navegador recorre los controles del formulario y, por cada uno que tenga atributo
name, construye un parname=valor. Los pares se unen con&. Un control sinnameno se envía, aunque el usuario lo haya rellenado.
Con este formulario:
<form action="https://httpbin.org/get" method="get">
<label for="ciudad">Ciudad</label>
<input type="text" id="ciudad" name="ciudad" value="Logroño">
<label for="noches">Noches</label>
<input type="number" id="noches" name="noches" value="3">
<button type="submit">Buscar hoteles</button>
</form>
Al pulsar el botón, el navegador irá a:
https://httpbin.org/get?ciudad=Logro%C3%B1o&noches=3
💡 Consejo:
httpbin.orges un servicio gratuito de pruebas que te devuelve lo que le envías en formato JSON. Usahttps://httpbin.org/getconmethod="get"yhttps://httpbin.org/postconmethod="post"para comprobar qué datos manda tu formulario sin necesidad de programar un servidor. La respuesta incluye un objetoargs(en GET) oform(en POST) con todos los pares recibidos.
GET vs. POST
| Aspecto | GET | POST |
|---|---|---|
| Dónde van los datos | En la URL (query string) | En el cuerpo de la petición |
| Visibles en la barra de direcciones | Sí | No |
| Se guardan en el historial y en los marcadores | Sí | No |
| Límite de tamaño | Sí (URLs de unos pocos miles de caracteres) | Prácticamente sin límite |
| Subir ficheros | No | Sí (con enctype="multipart/form-data") |
| Uso semántico | Consultar sin modificar nada | Crear o modificar algo en el servidor |
| Ejemplos | Buscador, filtros de una tienda, paginación | Registro, inicio de sesión, compra, comentario, contacto |
⚠️ Importante: Nunca uses GET para contraseñas o datos personales: quedarían a la vista en la URL, en el historial del navegador y en los registros de los servidores. Y recuerda que POST no cifra nada: los datos solo viajan protegidos si la página usa HTTPS.
💡 Consejo: Regla rápida: si al enviar el formulario “cambia algo en el mundo” (se crea una cuenta, se hace un pedido, se envía un mensaje), usa POST. Si solo “preguntas algo” (buscar, filtrar), usa GET.
3. Etiquetas: el elemento label
📘 Concepto:
<label>es el texto que identifica a un control. No es un simple texto al lado del campo: está asociado al control, de modo que los lectores de pantalla lo leen al entrar en el campo y, al hacer clic en la etiqueta, el foco va al campo (o se marca la casilla).
Asociación explícita (recomendada)
El atributo for del label coincide con el id del control:
<label for="email">Correo electrónico</label>
<input type="email" id="email" name="email">
Asociación implícita
El control va dentro del label:
<label>
Correo electrónico
<input type="email" name="email">
</label>
💡 Consejo: Lo más robusto es usar ambas a la vez en casillas y botones de opción: el control dentro del
labelyfor+id. En campos de texto, la asociación explícita es lo habitual.
✏️ Ejercicio: Crea una casilla de verificación con su
labely haz clic en el texto de la etiqueta. La casilla se marca. Ahora quita elfor(o saca el input del label) y vuelve a probar: ya no funciona. Ese es el “área de clic” extra que agradecen los usuarios de móvil y las personas con dificultades motoras.
⚠️ Importante:
nameeidson cosas distintas:idsirve para asociar ellabel(y para CSS y JavaScript) y debe ser único en la página;namees el nombre con el que el dato viaja al servidor. Suelen coincidir, pero no siempre (por ejemplo, en los botones de opción varios controles compartennamey cada uno tiene suid).
4. El elemento input y sus tipos
<input> es un elemento vacío cuyo comportamiento cambia por completo según el atributo type. Si el navegador no reconoce un tipo, lo trata como text, así que los tipos de HTML5 se pueden usar sin miedo.
Tipos de texto
<!-- Texto genérico -->
<label for="nombre">Nombre</label>
<input type="text" id="nombre" name="nombre">
<!-- Email: valida el formato y muestra un teclado con @ en el móvil -->
<label for="correo">Email</label>
<input type="email" id="correo" name="correo">
<!-- Contraseña: oculta los caracteres -->
<label for="clave">Contraseña</label>
<input type="password" id="clave" name="clave">
<!-- Teléfono: no valida el formato (varía por país), pero muestra el teclado numérico -->
<label for="telefono">Teléfono</label>
<input type="tel" id="telefono" name="telefono">
<!-- URL: valida que empiece por un esquema (https://...) -->
<label for="web">Página web</label>
<input type="url" id="web" name="web">
<!-- Búsqueda: algunos navegadores muestran un botón para borrar -->
<label for="buscar">Buscar productos</label>
<input type="search" id="buscar" name="q">
Tipos numéricos
<!-- Número: flechas para subir/bajar, teclado numérico en móvil -->
<label for="cantidad">Cantidad (1-10)</label>
<input type="number" id="cantidad" name="cantidad" min="1" max="10" step="1" value="1">
<!-- Rango: deslizador; el valor exacto no se ve sin JavaScript -->
<label for="volumen">Volumen</label>
<input type="range" id="volumen" name="volumen" min="0" max="100" step="5" value="50">
💡 Consejo: No uses
type="number"para datos que parecen números pero no son cantidades: códigos postales, números de tarjeta, DNI, teléfonos. Connumberse pierden los ceros iniciales, aparecen flechas absurdas y la rueda del ratón puede cambiar el valor sin querer. Usatype="text"coninputmode="numeric"y unpattern.
Tipos de fecha y hora
<label for="llegada">Fecha de llegada</label>
<input type="date" id="llegada" name="llegada" min="2026-10-01" max="2027-09-30">
<label for="hora">Hora de la reserva</label>
<input type="time" id="hora" name="hora" min="13:00" max="23:00" step="900">
<label for="cita">Fecha y hora de la cita</label>
<input type="datetime-local" id="cita" name="cita">
Existen también month (mes) y week (semana), aunque su soporte en navegadores de escritorio es irregular: compruébalo en caniuse.com antes de usarlos.
📘 Concepto: El navegador muestra la fecha en el formato del idioma del usuario (por ejemplo,
12/10/2026en España), pero el valor que se envía es siempre el formato estándarAAAA-MM-DD(2026-10-12). Lo mismo conmin,maxyvalue: se escriben siempre en formato estándar. Entime,step="900"significa saltos de 900 segundos (15 minutos).
Otros tipos
<!-- Selector de color: el valor es hexadecimal (#rrggbb) -->
<label for="color">Color favorito</label>
<input type="color" id="color" name="color" value="#2563eb">
<!-- Fichero: obliga a usar method="post" y enctype="multipart/form-data" -->
<label for="cv">Currículum (PDF o Word, máx. 2 MB)</label>
<input type="file" id="cv" name="cv" accept=".pdf,.doc,.docx">
<!-- Varias imágenes a la vez -->
<label for="fotos">Fotos del producto</label>
<input type="file" id="fotos" name="fotos" accept="image/*" multiple>
<!-- Oculto: no se ve, pero se envía -->
<input type="hidden" name="origen" value="campana-otono-2026">
⚠️ Importante: Un campo
hiddenno es secreto: cualquiera puede verlo en el código fuente y modificarlo con DevTools. Nunca guardes en él precios, permisos ni datos que el servidor no deba aceptar sin comprobar.
Casillas de verificación: checkbox
Para opciones independientes que se pueden marcar o no, una o varias:
<fieldset>
<legend>Intereses</legend>
<input type="checkbox" id="int-tec" name="intereses" value="tecnologia" checked>
<label for="int-tec">Tecnología</label>
<input type="checkbox" id="int-dep" name="intereses" value="deportes">
<label for="int-dep">Deportes</label>
<input type="checkbox" id="int-cine" name="intereses" value="cine">
<label for="int-cine">Cine</label>
</fieldset>
<input type="checkbox" id="acepto" name="acepto" value="si" required>
<label for="acepto">He leído y acepto la <a href="privacidad.html">política de privacidad</a></label>
- Solo se envían las casillas marcadas. Si marcas tecnología y cine se envía
intereses=tecnologia&intereses=cine. - Si no indicas
value, una casilla marcada envíaon. checkedla marca por defecto.
Botones de opción: radio
Para elegir una sola opción entre varias. Todos los botones del grupo comparten el mismo name:
<fieldset>
<legend>Tipo de envío</legend>
<input type="radio" id="envio-normal" name="envio" value="normal" checked>
<label for="envio-normal">Normal (3-5 días, gratis)</label>
<input type="radio" id="envio-urgente" name="envio" value="urgente">
<label for="envio-urgente">Urgente (24 h, 4,95 €)</label>
<input type="radio" id="envio-tienda" name="envio" value="tienda">
<label for="envio-tienda">Recogida en tienda</label>
</fieldset>
| Situación | Control |
|---|---|
| Elegir una opción de pocas (2-5) | radio |
| Elegir una opción de muchas | select |
| Elegir varias opciones | Varios checkbox |
| Activar o desactivar una sola cosa | Un checkbox |
⚠️ Importante: Si los botones de opción no comparten el mismo
name, el usuario podrá marcarlos todos a la vez. Y con el teclado, dentro de un grupo de radios uno se mueve con las flechas, no conTab.
Resumen de tipos de input
| Tipo | Uso | Validación nativa | Teclado en móvil |
|---|---|---|---|
text | Texto genérico | Solo la que añadas | Normal |
email | Correo electrónico | Formato de email | Con @ y . |
password | Contraseñas | Solo la que añadas | Normal, caracteres ocultos |
tel | Teléfono | Ninguna (solo con pattern) | Numérico |
url | Dirección web | Formato de URL | Con / y .com |
search | Búsqueda | Ninguna | Botón “Buscar” |
number | Cantidades | min, max, step | Numérico |
range | Valor aproximado en un rango | min, max, step | (Deslizador) |
date | Fecha | Fecha válida, min, max | (Calendario) |
time | Hora | Hora válida, min, max | (Selector) |
datetime-local | Fecha y hora | Válida, min, max | (Selector) |
month / week | Mes / semana | Válidos | (Selector) |
color | Color | Hexadecimal | (Paleta) |
file | Subir ficheros | accept (orientativo) | (Selector de archivos o cámara) |
checkbox | Opciones independientes | required | — |
radio | Una opción de un grupo | required | — |
hidden | Dato oculto | — | — |
submit / reset / button | Botones (mejor con button) | — | — |
5. Áreas de texto, listas y sugerencias
textarea: texto de varias líneas
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows="6" cols="40" maxlength="1000"
placeholder="Cuéntanos en qué podemos ayudarte"></textarea>
| Atributo | Efecto |
|---|---|
rows / cols | Altura (en líneas) y anchura (en caracteres) iniciales; mejor controlar el tamaño con CSS |
maxlength / minlength | Longitud máxima / mínima del texto |
⚠️ Importante:
textareano es un elemento vacío: necesita su etiqueta de cierre. El valor inicial va entre las etiquetas, no en un atributovalue. Y cuidado con los espacios:<textarea> </textarea>empieza con un espacio dentro, con lo que elplaceholderno se verá yrequiredlo dará por rellenado.
select: lista desplegable
<label for="provincia">Provincia</label>
<select id="provincia" name="provincia" required>
<option value="">Elige una provincia</option>
<option value="A">Alicante</option>
<option value="CS">Castellón</option>
<option value="V" selected>Valencia</option>
</select>
- Lo que se envía es el
valuedeloption; lo que ve el usuario, su texto. Si no hayvalue, se envía el texto. selectedmarca la opción por defecto.- La primera opción con
value=""actúa como marcador: si elselectesrequired, el usuario está obligado a elegir otra.
optgroup: grupos de opciones
<optgroup label="..."> agrupa varias option bajo un título no seleccionable (por ejemplo, “Utilitarios” y “SUV” en una lista de modelos de coche). Una option con disabled aparece pero no se puede elegir. Lo verás en acción en el formulario de contacto de la sección 12.
Selección múltiple
Con el atributo multiple (y size="4" para mostrar cuatro filas a la vez), el usuario puede elegir varias opciones manteniendo pulsado Ctrl; se envía un par name=valor por cada opción elegida.
💡 Consejo: El
select multiplees poco intuitivo (hay que saber usarCtrl) y muy incómodo en móvil. Para pocas opciones, es mucho mejor un grupo decheckbox.
datalist: sugerencias de autocompletado
<datalist> ofrece una lista de sugerencias para un campo de texto, pero el usuario puede escribir cualquier otro valor. Se asocia con el atributo list del input:
<label for="navegador">¿Qué navegador usas?</label>
<input type="text" id="navegador" name="navegador" list="lista-navegadores">
<datalist id="lista-navegadores">
<option value="Chrome"></option>
<option value="Firefox"></option>
<option value="Safari"></option>
<option value="Edge"></option>
<option value="Opera"></option>
</datalist>
| Necesidad | Control |
|---|---|
| El usuario debe elegir de una lista cerrada | select |
| Sugerencias, pero se admite cualquier valor | input + datalist |
6. Botones
<button type="submit">Enviar solicitud</button>
<button type="reset">Borrar todo</button>
<button type="button">Mostrar ayuda</button>
| Tipo | Comportamiento |
|---|---|
submit | Envía el formulario (valor por defecto dentro de un form) |
reset | Devuelve todos los campos a su valor inicial |
button | No hace nada por sí mismo; se usa con JavaScript |
⚠️ Importante: Un
<button>dentro de unformsintypees de tiposubmit. Si pones un botón para, por ejemplo, mostrar u ocultar la contraseña y olvidastype="button", al pulsarlo enviarás el formulario. Escribe siempre eltypede forma explícita.
💡 Consejo: Evita el botón
reset. Es muy fácil pulsarlo por error y perder todo lo escrito, y casi nadie lo necesita. Muchos expertos en usabilidad recomiendan no incluirlo.
button frente a input type="submit"
El antiguo <input type="submit" value="Enviar"> funciona igual que <button type="submit">Enviar</button>, pero solo admite texto plano. <button> puede contener HTML (un icono SVG, texto con formato), así que es el que usaremos.
Botones con destino distinto
Un botón puede sobrescribir los atributos del formulario con formaction, formmethod o formnovalidate:
<form action="/articulos/guardar" method="post">
<label for="titulo">Título del artículo</label>
<input type="text" id="titulo" name="titulo" required>
<button type="submit">Publicar</button>
<!-- Guarda como borrador sin exigir los campos obligatorios -->
<button type="submit" formaction="/articulos/borrador" formnovalidate>Guardar borrador</button>
</form>
7. Agrupar controles: fieldset y legend
📘 Concepto:
<fieldset>agrupa controles relacionados y<legend>(su primer hijo) le pone título. Los lectores de pantalla anuncian la leyenda al entrar en el grupo, lo que da contexto a cada control. Es como las secciones de un impreso: “Datos personales”, “Datos de envío”, “Forma de pago”.
<form action="https://httpbin.org/post" method="post">
<fieldset>
<legend>Datos de envío</legend>
<label for="direccion">Dirección</label>
<input type="text" id="direccion" name="direccion" autocomplete="street-address">
<label for="cp">Código postal</label>
<input type="text" id="cp" name="cp" inputmode="numeric" autocomplete="postal-code">
</fieldset>
<fieldset>
<legend>Forma de pago</legend>
<input type="radio" id="pago-tarjeta" name="pago" value="tarjeta" checked>
<label for="pago-tarjeta">Tarjeta</label>
<input type="radio" id="pago-bizum" name="pago" value="bizum">
<label for="pago-bizum">Bizum</label>
</fieldset>
<button type="submit">Finalizar compra</button>
</form>
Por defecto, el navegador dibuja un borde alrededor del fieldset con la leyenda incrustada en él. Puedes cambiarlo con CSS.
⚠️ Importante: Los grupos de
radioy decheckboxrelacionados deben ir siempre dentro de unfieldsetconlegend. Sin él, un usuario de lector de pantalla oirá “Sí, botón de opción” sin saber cuál es la pregunta.
💡 Consejo: El atributo
disableden unfieldsetdesactiva todos los controles que contiene. Muy útil para bloquear una sección completa del formulario.
8. Atributos de los controles
Atributos fundamentales
| Atributo | Se aplica a | Efecto |
|---|---|---|
name | Todos | Nombre con el que se envía el dato. Sin name, no se envía |
id | Todos | Identificador único (para el label, CSS y JavaScript) |
value | input, option, button | Valor inicial (en texto) o valor que se envía (en checkbox, radio, option) |
placeholder | Campos de texto, textarea | Texto de ejemplo que desaparece al escribir |
required | Casi todos | Campo obligatorio |
disabled | Todos | Desactivado: no se puede usar, no recibe el foco y no se envía |
readonly | Campos de texto | Solo lectura: se puede seleccionar y copiar, sí se envía |
autofocus | Todos | Recibe el foco al cargar la página (solo uno por página) |
autocomplete | Casi todos | Indica qué dato es para que el navegador lo autocomplete |
Atributos de restricción
| Atributo | Se aplica a | Ejemplo | Efecto |
|---|---|---|---|
minlength / maxlength | Texto, textarea | minlength="8" maxlength="64" | Longitud mínima / máxima en caracteres |
min / max | Números, fechas, horas, range | min="18" max="99" | Valor mínimo / máximo |
step | Números, fechas, horas, range | step="0.01" | Incremento permitido (any para cualquiera) |
pattern | Texto, tel, email, url, password, search | pattern="[0-9]{5}" | Expresión regular que debe cumplir el valor completo |
multiple | email, file, select | multiple | Admite varios valores |
accept | file | accept="image/png, image/jpeg" | Tipos de fichero sugeridos |
Atributos de ayuda en móvil
| Atributo | Ejemplo | Efecto |
|---|---|---|
inputmode | numeric, decimal, tel, email, url, search | Qué teclado virtual se muestra, sin cambiar el tipo de dato |
enterkeyhint | search, send, next, done, go | Qué texto o icono aparece en la tecla Intro del teclado virtual |
spellcheck | false | Desactiva el corrector ortográfico (útil en nombres de usuario o códigos) |
placeholder no es una etiqueta
<!-- ❌ Mal: sin label, el placeholder desaparece al escribir -->
<input type="email" name="email" placeholder="Email">
<!-- ✅ Bien: label visible + placeholder con un ejemplo de formato -->
<label for="email-2">Email</label>
<input type="email" id="email-2" name="email" placeholder="nombre@ejemplo.es">
⚠️ Importante: El
placeholderno sustituye allabel. Desaparece al escribir (el usuario olvida qué campo era), suele tener poco contraste y no todos los lectores de pantalla lo leen. Úsalo solo para ejemplos de formato, nunca para instrucciones imprescindibles.
disabled frente a readonly
| Característica | disabled | readonly |
|---|---|---|
| ¿Se puede editar? | No | No |
| ¿Recibe el foco? | No | Sí |
| ¿Se envía al servidor? | No | Sí |
| ¿Se valida? | No | No |
| Aspecto por defecto | Atenuado (gris) | Normal |
| Uso típico | Opción no disponible ahora | Dato calculado o que el usuario no debe cambiar (número de pedido) |
pattern: expresiones regulares
pattern comprueba que el valor completo cumple una expresión regular (no hace falta poner ^ ni $):
<!-- Código postal español: exactamente 5 cifras -->
<label for="codigo-postal">Código postal</label>
<input type="text" id="codigo-postal" name="cp" inputmode="numeric"
pattern="[0-9]{5}" title="5 cifras, por ejemplo 28013" required>
<!-- DNI: 8 cifras y una letra -->
<label for="dni">DNI</label>
<input type="text" id="dni" name="dni" pattern="[0-9]{8}[A-Za-z]"
title="8 números seguidos de una letra, por ejemplo 12345678Z">
<!-- Móvil o fijo español: 9 cifras que empiezan por 6, 7, 8 o 9 -->
<label for="tel">Teléfono</label>
<input type="tel" id="tel" name="tel" pattern="[6-9][0-9]{8}"
title="9 cifras, sin espacios" autocomplete="tel-national">
| Fragmento | Significado |
|---|---|
[0-9] | Una cifra |
[A-Za-z] | Una letra (sin ñ ni tildes) |
{5} | Exactamente 5 veces |
{3,16} | Entre 3 y 16 veces |
+ | Una o más veces |
? | Opcional (0 o 1 vez) |
. | Cualquier carácter |
💡 Consejo: El texto del atributo
titlese añade al mensaje de error del navegador cuando elpatternno se cumple. Pero es mejor mostrar el formato esperado en un texto de ayuda visible junto al campo (sección 10).
⚠️ Importante: Los navegadores actuales evalúan
patterncon el modovde las expresiones regulares de JavaScript, que es más estricto. Si necesitas un guion literal dentro de corchetes, escápalo:[A-Za-z0-9\-]. Si no, el patrón será inválido y el navegador lo ignorará sin avisar.
autocomplete: ayuda al usuario a rellenar
El atributo autocomplete dice al navegador qué dato concreto pide el campo, para que pueda ofrecer los valores guardados del usuario:
| Valor | Dato |
|---|---|
name | Nombre completo |
given-name / family-name | Nombre / apellidos |
email | Correo electrónico |
tel | Teléfono |
username | Nombre de usuario |
new-password | Contraseña nueva (registro, cambio): el navegador puede sugerir una segura |
current-password | Contraseña actual (inicio de sesión) |
street-address | Dirección |
postal-code | Código postal |
address-level2 | Localidad |
bday | Fecha de nacimiento |
off | No autocompletar |
💡 Consejo: Usar bien
autocompletees una de las mejoras de usabilidad más baratas que existen: el usuario rellena el formulario con dos toques. Además, es un requisito de accesibilidad (criterio 1.3.5 de WCAG). No pongasautocomplete="off"salvo que haya un motivo real.
9. Validación nativa del navegador
HTML5 incluye validación en el navegador sin escribir una línea de JavaScript. Al pulsar el botón de envío, si algún control no cumple sus restricciones, el navegador cancela el envío, lleva el foco al primer campo erróneo y muestra un mensaje.
Por ejemplo, con un campo required vacío, Chrome en español muestra “Completa este campo.”, y con un pattern incumplido, “Utiliza un formato que coincida con el solicitado.”. Los textos exactos dependen del navegador y de su idioma.
⚠️ Importante: La validación del navegador es una ayuda para el usuario, no una medida de seguridad. Cualquiera puede desactivarla con DevTools, añadir
novalidateo enviar peticiones directamente al servidor sin usar tu formulario. El servidor debe validar siempre todos los datos de nuevo.
Desactivar la validación
Añade novalidate al form para desactivarla en todo el formulario (por ejemplo, si validas con JavaScript) o formnovalidate a un botón concreto, como el de “Guardar borrador” de la sección 6.
Pseudoclases de validación (adelanto de CSS)
CSS puede dar estilo a los campos según su estado de validación:
| Pseudoclase | Se aplica cuando… |
|---|---|
:required / :optional | El campo es / no es obligatorio |
:valid / :invalid | El valor cumple / no cumple las restricciones (desde el primer momento) |
:user-valid / :user-invalid | Igual, pero solo después de que el usuario haya interactuado con el campo |
:placeholder-shown | Se está mostrando el placeholder (campo vacío) |
:disabled / :enabled | El control está desactivado / activo |
:checked | La casilla o el botón de opción está marcado |
:focus / :focus-visible | El control tiene el foco (:focus-visible solo cuando procede mostrarlo, p. ej., con teclado) |
/* Borde rojo SOLO cuando el usuario ya ha tocado el campo y es incorrecto */
input:user-invalid {
border: 2px solid #dc2626;
}
/* Borde verde cuando es correcto tras interactuar */
input:user-valid {
border: 2px solid #16a34a;
}
/* Marca un asterisco en las etiquetas de campos obligatorios (requiere estructura adecuada) */
.campo:has(:required) label::after {
content: " *";
color: #dc2626;
}
💡 Consejo: Usa
:user-invaliden lugar de:invalid. Con:invalid, todos los campos obligatorios aparecen en rojo nada más cargar la página, antes de que el usuario haya escrito nada, lo que resulta agresivo y confuso.
⚠️ Importante: No indiques los errores solo con color: las personas con daltonismo pueden no distinguir el rojo del verde. Acompaña el color de un texto explicativo o de un icono.
10. Accesibilidad de formularios
Un formulario inaccesible es una puerta cerrada: si una persona no puede registrarse, comprar o contactar, el resto de la web no le sirve de nada.
Reglas de oro
| Regla | Cómo |
|---|---|
| Todo control tiene una etiqueta visible | label con for / id |
| Los grupos tienen título | fieldset + legend |
Las instrucciones van antes del campo, no solo en el placeholder | Texto de ayuda asociado con aria-describedby |
| Se indica qué es obligatorio | required + una marca visible explicada al principio (“Los campos con * son obligatorios”) |
| Los errores se explican con texto | Qué falla y cómo corregirlo, junto al campo |
| Se puede usar con teclado | Orden lógico de Tab; foco siempre visible |
Se usa el tipo y el autocomplete correctos | Teclado adecuado en móvil y autocompletado |
| No se bloquea el pegado | Los gestores de contraseñas necesitan pegar |
Textos de ayuda con aria-describedby
aria-describedby asocia un texto adicional a un control: el lector de pantalla lo lee después de la etiqueta.
<label for="password">Contraseña</label>
<input type="password" id="password" name="password"
minlength="10" required autocomplete="new-password"
aria-describedby="ayuda-password">
<p id="ayuda-password">Mínimo 10 caracteres. Te recomendamos combinar palabras, números y símbolos.</p>
El lector anunciará: “Contraseña, campo de contraseña, obligatorio. Mínimo 10 caracteres. Te recomendamos combinar…“.
Mensajes de error accesibles
Cuando el servidor (o tu JavaScript) detecta un error, márcalo así:
<label for="email-error">Email</label>
<input type="email" id="email-error" name="email" value="ana@"
aria-invalid="true" aria-describedby="error-email">
<p id="error-email" class="error">
El email no es válido. Debe tener el formato nombre@dominio.es
</p>
💡 Consejo: Prueba tus formularios solo con el teclado:
Tabpara avanzar,Shift + Tabpara retroceder,Espaciopara marcar casillas, flechas para los botones de opción eIntropara enviar. Si te quedas atascado en algún punto, o no ves dónde está el foco, el formulario tiene un problema de accesibilidad.
⚠️ Importante: Nunca elimines el contorno del foco (
outline: none) sin sustituirlo por otro indicador bien visible. Es la única forma que tienen los usuarios de teclado de saber dónde están.
11. Ejemplo completo: formulario de registro
💻 Ejemplo:
registro.html(envía los datos a httpbin para que veas el resultado):
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Crear cuenta | Club de Lectura</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 0 auto; padding: 16px; }
fieldset { border: 1px solid #cbd5e1; border-radius: 8px; margin-bottom: 20px; padding: 16px; }
legend { font-weight: bold; padding: 0 6px; }
.campo { margin-bottom: 14px; }
.campo label { display: block; font-weight: 600; margin-bottom: 4px; }
.campo input, .campo select { width: 100%; padding: 8px; font-size: 1rem; box-sizing: border-box; }
.ayuda { font-size: 0.875rem; color: #475569; margin: 4px 0 0; }
input:user-invalid, select:user-invalid { border: 2px solid #dc2626; }
button { padding: 10px 20px; font-size: 1rem; }
</style>
</head>
<body>
<h1>Crear cuenta</h1>
<p>Los campos marcados con <span aria-hidden="true">*</span> (asterisco) son obligatorios.</p>
<form action="https://httpbin.org/post" method="post">
<fieldset>
<legend>Datos personales</legend>
<div class="campo">
<label for="nombre">Nombre <span aria-hidden="true">*</span></label>
<input type="text" id="nombre" name="nombre" required autocomplete="given-name">
</div>
<div class="campo">
<label for="apellidos">Apellidos <span aria-hidden="true">*</span></label>
<input type="text" id="apellidos" name="apellidos" required autocomplete="family-name">
</div>
<div class="campo">
<label for="nacimiento">Fecha de nacimiento <span aria-hidden="true">*</span></label>
<input type="date" id="nacimiento" name="nacimiento" required
min="1920-01-01" max="2010-12-31" autocomplete="bday"
aria-describedby="ayuda-nacimiento">
<p id="ayuda-nacimiento" class="ayuda">Debes tener al menos 16 años.</p>
</div>
</fieldset>
<fieldset>
<legend>Datos de acceso</legend>
<div class="campo">
<label for="email">Email <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required autocomplete="email"
placeholder="nombre@ejemplo.es">
</div>
<div class="campo">
<label for="usuario">Nombre de usuario <span aria-hidden="true">*</span></label>
<input type="text" id="usuario" name="usuario" required
pattern="[a-z0-9_]{3,16}" autocomplete="username"
spellcheck="false" autocapitalize="off"
aria-describedby="ayuda-usuario">
<p id="ayuda-usuario" class="ayuda">De 3 a 16 caracteres: letras minúsculas, números o guion bajo.</p>
</div>
<div class="campo">
<label for="clave">Contraseña <span aria-hidden="true">*</span></label>
<input type="password" id="clave" name="clave" required
minlength="10" autocomplete="new-password"
aria-describedby="ayuda-clave">
<p id="ayuda-clave" class="ayuda">Mínimo 10 caracteres.</p>
</div>
</fieldset>
<fieldset>
<legend>Géneros literarios favoritos</legend>
<input type="checkbox" id="g-novela" name="generos" value="novela">
<label for="g-novela">Novela</label><br>
<input type="checkbox" id="g-ensayo" name="generos" value="ensayo">
<label for="g-ensayo">Ensayo</label><br>
<input type="checkbox" id="g-poesia" name="generos" value="poesia">
<label for="g-poesia">Poesía</label>
</fieldset>
<fieldset>
<legend>¿Con qué frecuencia quieres recibir el boletín?</legend>
<input type="radio" id="b-semanal" name="boletin" value="semanal" checked>
<label for="b-semanal">Semanal</label><br>
<input type="radio" id="b-mensual" name="boletin" value="mensual">
<label for="b-mensual">Mensual</label><br>
<input type="radio" id="b-nunca" name="boletin" value="nunca">
<label for="b-nunca">No quiero recibirlo</label>
</fieldset>
<div class="campo">
<input type="checkbox" id="privacidad" name="privacidad" value="aceptada" required>
<label for="privacidad" style="display: inline">
He leído y acepto la <a href="privacidad.html">política de privacidad</a>
<span aria-hidden="true">*</span>
</label>
</div>
<input type="hidden" name="origen" value="web">
<button type="submit">Crear cuenta</button>
</form>
</body>
</html>
Resultado esperado: al pulsar Crear cuenta con campos vacíos, el navegador señala el primer campo obligatorio. Si rellenas todo correctamente, httpbin devuelve un JSON con un objeto "form" que contiene todos tus datos (nombre, apellidos, generos, boletin, origen…).
💡 Consejo: En la respuesta de httpbin fíjate en que la contraseña viaja en texto plano dentro de la petición. Solo HTTPS la protege por el camino, y es el servidor quien debe guardarla cifrada (con una función de hash), nunca tal cual.
12. Ejemplo completo: formulario de contacto
💻 Ejemplo:
contacto.html:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contacto | Estudio de Diseño Lumen</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 16px; }
.campo { margin-bottom: 16px; }
label { display: block; font-weight: 600; margin-bottom: 4px; }
input, select, textarea { width: 100%; padding: 8px; font: inherit; box-sizing: border-box; }
textarea { resize: vertical; }
.ayuda { font-size: 0.875rem; color: #475569; margin: 4px 0 0; }
:user-invalid { border: 2px solid #dc2626; }
button { padding: 10px 24px; font: inherit; cursor: pointer; }
</style>
</head>
<body>
<h1>Contacta con nosotros</h1>
<p>Respondemos en menos de 48 horas laborables. Todos los campos son obligatorios salvo que se indique lo contrario.</p>
<form action="https://httpbin.org/post" method="post" enctype="multipart/form-data">
<div class="campo">
<label for="nombre">Nombre y apellidos</label>
<input type="text" id="nombre" name="nombre" required autocomplete="name">
</div>
<div class="campo">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email"
placeholder="nombre@ejemplo.es">
</div>
<div class="campo">
<label for="telefono">Teléfono (opcional)</label>
<input type="tel" id="telefono" name="telefono" autocomplete="tel"
pattern="[+0-9 ]{9,15}" aria-describedby="ayuda-telefono">
<p id="ayuda-telefono" class="ayuda">Solo si prefieres que te llamemos.</p>
</div>
<div class="campo">
<label for="motivo">Motivo de la consulta</label>
<select id="motivo" name="motivo" required>
<option value="">Elige un motivo</option>
<optgroup label="Proyectos">
<option value="web">Diseño web</option>
<option value="marca">Identidad de marca</option>
</optgroup>
<optgroup label="Otros">
<option value="empleo">Trabajar con nosotros</option>
<option value="otro">Otro</option>
</optgroup>
</select>
</div>
<div class="campo">
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows="6" required
minlength="20" maxlength="2000"
aria-describedby="ayuda-mensaje"></textarea>
<p id="ayuda-mensaje" class="ayuda">Entre 20 y 2000 caracteres.</p>
</div>
<div class="campo">
<label for="adjunto">Adjuntar fichero (opcional)</label>
<input type="file" id="adjunto" name="adjunto" accept=".pdf,.jpg,.jpeg,.png"
aria-describedby="ayuda-adjunto">
<p id="ayuda-adjunto" class="ayuda">PDF, JPG o PNG.</p>
</div>
<div class="campo">
<input type="checkbox" id="privacidad" name="privacidad" value="aceptada" required>
<label for="privacidad" style="display: inline">
Acepto que mis datos se usen para responder a esta consulta según la
<a href="privacidad.html">política de privacidad</a>.
</label>
</div>
<button type="submit">Enviar mensaje</button>
</form>
</body>
</html>
Observa que, como incluye un input type="file", el formulario usa method="post" y enctype="multipart/form-data". Sin ese enctype, solo se enviaría el nombre del fichero, no su contenido.
💡 Consejo: Si tu web es estática (sin servidor propio, como GitHub Pages), existen servicios externos de formularios que reciben el envío y te lo reenvían por correo. Basta con poner su URL en el
action. Revisa siempre su política de privacidad antes de enviarles datos de tus usuarios.
13. Errores comunes y su solución
| Error | Consecuencia | Solución |
|---|---|---|
Control sin name | El dato no se envía | Pon name a todo control que deba enviarse |
label sin for o con un for que no coincide con ningún id | Etiqueta no asociada: inaccesible, sin área de clic | for igual al id del control |
Usar placeholder como etiqueta | El usuario olvida qué campo era; poco contraste | label visible siempre |
button sin type | Envía el formulario por sorpresa | Escribe siempre type="submit" o type="button" |
Radios con distinto name | Se pueden marcar todos | Mismo name en todo el grupo |
Grupos de radios o checkbox sin fieldset | El lector de pantalla no anuncia la pregunta | fieldset + legend |
| GET con contraseñas | La contraseña aparece en la URL y en el historial | method="post" y HTTPS |
Subir ficheros sin enctype | Solo se envía el nombre del fichero | method="post" + enctype="multipart/form-data" |
| Confiar solo en la validación HTML | Datos maliciosos o erróneos llegan al servidor | Valida siempre también en el servidor |
14. Ejercicios
Ejercicio 1: Buscador real
Crea un formulario de búsqueda con method="get" cuyo action sea https://www.google.com/search, con un campo type="search" llamado q y su label. Añade un segundo campo oculto llamado as_sitesearch con el valor developer.mozilla.org. Prueba a buscar “flexbox” y observa la URL resultante: ¿cómo aparecen los datos?
Ejercicio 2: Inicio de sesión
Crea un formulario de inicio de sesión con email, contraseña (con autocomplete="current-password"), una casilla “Recordarme” y un botón “Entrar”. Envíalo a https://httpbin.org/post y comprueba en la respuesta qué datos llegan cuando la casilla está marcada y cuando no.
Ejercicio 3: Catálogo de tipos
Crea una página que muestre todos los tipos de input vistos en el módulo, cada uno con su label y un texto de ayuda que explique para qué sirve. Ábrela en el ordenador y en tu móvil (puedes acceder desde el móvil a Live Server usando la IP de tu ordenador en la misma red Wi-Fi, por ejemplo http://192.168.1.20:5500; si no carga, revisa el cortafuegos de Windows y la opción de host de Live Server) y anota qué teclado o selector aparece en cada tipo.
Ejercicio 4: Reserva de restaurante
Crea un formulario de reserva con: nombre, teléfono (con pattern), número de comensales (de 1 a 12), fecha (a partir de hoy y como máximo dentro de 3 meses: escribe las fechas a mano), hora (entre las 13:00 y las 16:00, cada 30 minutos), zona preferida (radio: interior, terraza, sin preferencia), alergias (checkbox: gluten, lactosa, frutos secos, marisco) y observaciones (textarea, máx. 300 caracteres). Agrupa con fieldset y valida con atributos HTML.
Ejercicio 5: Validación con estilo
Partiendo del ejercicio 4, añade CSS para que los campos inválidos muestren un borde rojo solo después de que el usuario interactúe con ellos (:user-invalid) y un borde verde cuando sean válidos. Añade textos de ayuda asociados con aria-describedby y comprueba que el formulario se puede rellenar completo solo con el teclado.
Ejercicio 6: Formulario de pedido de pizza
Crea un formulario con: tamaño (radio: pequeña, mediana, familiar), masa (select con optgroup “Clásicas” y “Especiales”), ingredientes extra (checkbox, al menos 6), número de pizzas (number del 1 al 10), dirección de entrega (con autocomplete adecuados) y un campo de cupón descuento con pattern de 6 caracteres en mayúsculas y números. Añade un botón “Pedir” y otro “Guardar para luego” que use formnovalidate.
Ejercicio 7: Solicitud de empleo
Crea un formulario de solicitud de empleo que permita adjuntar el currículum en PDF (accept), una carta de presentación en textarea, el puesto al que se opta con datalist de sugerencias, la fecha de disponibilidad, el salario deseado con range y una casilla obligatoria de aceptación de la política de privacidad. Asegúrate de usar el method y el enctype correctos y verifica con httpbin que el fichero se envía.
Ejercicio 8: Auditoría de accesibilidad
Toma este formulario y corrige todos sus problemas (hay al menos 10). Después, pásale Lighthouse y la extensión WAVE o axe DevTools para comprobar que no quedan errores de accesibilidad:
<form action="/enviar" method="get">
<input type="text" placeholder="Nombre">
<input type="text" name="email" placeholder="Email">
<input type="number" name="cp" placeholder="Código postal">
Sexo:
<input type="radio" name="s1" value="h"> Hombre
<input type="radio" name="s2" value="m"> Mujer
<input type="password" name="pass" placeholder="Contraseña">
<textarea name="comentario"> </textarea>
<input type="checkbox" name="acepto"> Acepto las condiciones
<button>Mostrar contraseña</button>
<input type="submit" value="Enviar">
<input type="reset">
</form>
Resumen
| Concepto | Sintaxis / idea clave |
|---|---|
| Formulario | <form action="URL" method="post"> |
| GET / POST | GET: consultas, datos en la URL. POST: crear o modificar, datos en el cuerpo |
| Subir ficheros | method="post" + enctype="multipart/form-data" |
| Etiqueta | <label for="id"> asociado al id del control |
| Envío de datos | Pares name=valor; sin name no se envía |
| Campo de texto | <input type="text" id="..." name="..."> |
| Tipos HTML5 | email, password, tel, url, search, number, range, date, time, datetime-local, month, week, color, file, hidden |
| Casillas | type="checkbox" (varias opciones), type="radio" (una opción, mismo name) |
| Texto largo | <textarea rows="5"></textarea> |
| Lista desplegable | <select> + <option value="..."> + <optgroup label="..."> |
| Sugerencias | <input list="id"> + <datalist id="id"> |
| Botones | <button type="submit">, type="reset", type="button" |
| Agrupar | <fieldset> + <legend> |
| Obligatorio | required |
| Longitud | minlength, maxlength |
| Rango | min, max, step |
| Formato | pattern="[0-9]{5}" + texto de ayuda |
| No editable | disabled (no se envía) / readonly (sí se envía) |
| Autocompletado | autocomplete="email", "new-password", "postal-code"… |
| Teclado móvil | inputmode="numeric", enterkeyhint="send" |
| Sin validación | novalidate en el form, formnovalidate en un botón |
| CSS de validación | :user-invalid, :user-valid, :required, :checked, :focus-visible |
| Ayuda accesible | aria-describedby="id-del-texto" |
| Error accesible | aria-invalid="true" + mensaje asociado |
| Seguridad | La validación HTML no protege: valida siempre en el servidor |