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 form con action y method, y saber elegir entre GET y POST
  • Asociar correctamente cada control con su label
  • Conocer todos los tipos de input de HTML5 y cuándo usar cada uno
  • Usar textarea, select, option, optgroup y datalist
  • Distinguir los tipos de botón: submit, reset y button
  • Agrupar controles relacionados con fieldset y legend
  • 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 :valid e :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 par name=valor. Los pares se unen con &. Un control sin name no 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.org es un servicio gratuito de pruebas que te devuelve lo que le envías en formato JSON. Usa https://httpbin.org/get con method="get" y https://httpbin.org/post con method="post" para comprobar qué datos manda tu formulario sin necesidad de programar un servidor. La respuesta incluye un objeto args (en GET) o form (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 label y for + id. En campos de texto, la asociación explícita es lo habitual.

✏️ Ejercicio: Crea una casilla de verificación con su label y haz clic en el texto de la etiqueta. La casilla se marca. Ahora quita el for (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: name e id son cosas distintas: id sirve para asociar el label (y para CSS y JavaScript) y debe ser único en la página; name es 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 comparten name y cada uno tiene su id).


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. Con number se pierden los ceros iniciales, aparecen flechas absurdas y la rueda del ratón puede cambiar el valor sin querer. Usa type="text" con inputmode="numeric" y un pattern.

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/2026 en España), pero el valor que se envía es siempre el formato estándar AAAA-MM-DD (2026-10-12). Lo mismo con min, max y value: se escriben siempre en formato estándar. En time, 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 hidden no 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ía on.
  • checked la 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 con Tab.

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: textarea no es un elemento vacío: necesita su etiqueta de cierre. El valor inicial va entre las etiquetas, no en un atributo value. Y cuidado con los espacios: <textarea> </textarea> empieza con un espacio dentro, con lo que el placeholder no se verá y required lo 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 value del option; lo que ve el usuario, su texto. Si no hay value, se envía el texto.
  • selected marca la opción por defecto.
  • La primera opción con value="" actúa como marcador: si el select es required, 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 multiple es poco intuitivo (hay que saber usar Ctrl) y muy incómodo en móvil. Para pocas opciones, es mucho mejor un grupo de checkbox.

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 un form sin type es de tipo submit. Si pones un botón para, por ejemplo, mostrar u ocultar la contraseña y olvidas type="button", al pulsarlo enviarás el formulario. Escribe siempre el type de 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 radio y de checkbox relacionados deben ir siempre dentro de un fieldset con legend. 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 disabled en un fieldset desactiva 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 placeholder no sustituye al label. 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 title se añade al mensaje de error del navegador cuando el pattern no 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 pattern con el modo v de 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 autocomplete es 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 pongas autocomplete="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 novalidate o 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-invalid en 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: Tab para avanzar, Shift + Tab para retroceder, Espacio para marcar casillas, flechas para los botones de opción e Intro para 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