Con HTML semántico ya sabes estructurar una página para que tenga significado. Ahora llega la pieza que conecta el contenido con el usuario: los formularios. Son la forma en que la web recoge datos —un registro, una búsqueda, un pedido— y la mayoría de sus capacidades vienen del propio HTML, sin necesidad de JavaScript.
Cuando alguien rellena un formulario en tu aplicación, el navegador ya sabe muchas cosas: que un campo de email tiene que tener arroba, que un campo numérico no acepta letras, que un campo marcado como requerido no puede estar vacío. El problema es que eso no ocurre solo: tienes que pedírselo con el HTML correcto. Y la mayoría del tiempo no se pide.
Seguimos construyendo el Overwatch Team Builder. Esta vez añadimos el formulario para incorporar héroes a la plantilla: nombre, correo, estadísticas y rol. Sin tocar JavaScript.
El elemento <form> y por qué importa#
Un <form> no es un <div> con estilos de formulario. Tiene comportamiento propio: al pulsar
el botón de tipo submit, recoge los valores de todos sus campos, los valida y los envía. Sin
<form>, el botón no hace nada por defecto y la validación nativa no se activa.
<form action="/heroes" method="post">
<!-- campos aquí -->
<!-- type="submit" activa la validación y el envío -->
<button type="submit">Añadir héroe</button>
</form>
<!-- action: URL a la que se envían los datos al pulsar el botón de envío -->
<!-- method: "post" envía los datos en el cuerpo de la petición (lo habitual para formularios) -->action indica a dónde van los datos; method si se envían como GET (en la URL) o POST
(en el cuerpo). En frontend puro, a menudo se intercepta el submit con JavaScript, pero el
<form> sigue siendo el contenedor correcto: da semántica, activa validación y permite navegar
por los campos con el teclado.
Tipos de <input>: el tipo correcto no es un detalle menor#
El atributo type le dice al navegador qué clase de dato espera el campo. La consecuencia más
visible en 2026 es el teclado en móvil: type="email" muestra el teclado con @ a mano;
type="number" muestra el teclado numérico; type="text" siempre muestra el teclado completo.
Pero el tipo también activa validación automática:
<!-- El navegador valida que sea un email válido antes de enviar -->
<input type="email" name="contacto" />
<!-- Solo acepta enteros; min y max fijan el rango permitido -->
<input type="number" name="partidas" min="0" />
<!-- Selector de fecha nativo: sin librerías, sin JavaScript -->
<input type="date" name="fecha" />Los tipos más usados en empresa: text, email, password, number, tel, url, date,
checkbox, radio, file, hidden, submit. type="text" es el fallback cuando el
navegador no reconoce el tipo, así que escribir el tipo correcto nunca rompe nada: en el peor
caso lo trata como texto.
No todo es <input>. Para un texto largo —un comentario, una descripción— está <textarea>,
que crece a varias líneas en vez de una; y para elegir de una lista cerrada de opciones,
<select> con sus <option>. Ambos se asocian a su <label> igual que un <input>, con
for e id.
<!-- textarea: campo de texto de varias líneas -->
<label for="notas">Notas del héroe</label>
<!-- rows fija la altura inicial visible -->
<!-- el contenido va entre la etiqueta de apertura y la de cierre, no en un atributo -->
<textarea id="notas" name="notas" rows="4">
</textarea><!-- select: lista cerrada de opciones; el usuario elige una -->
<label for="rol">Rol</label>
<!-- name es el nombre del dato que viaja al servidor -->
<select id="rol" name="rol">
<!-- opción vacía como estado inicial -->
<option value="">-- Elige un rol --</option>
<!-- value es el dato que se envía; el texto es lo que ve el usuario -->
<option value="tanque">Tanque</option>
<option value="daño">Daño</option>
<option value="apoyo">Apoyo</option>
</select>Usa <textarea> cuando el usuario necesita escribir un texto largo con saltos de línea; usa <select>
cuando las opciones son fijas y conocidas de antemano y el usuario debe elegir exactamente una.
Asociar <label> al campo: obligatorio, no opcional#
Un <label> sin vinculación no sirve de nada técnicamente. Hay dos formas de asociarlo.
La primera usa el atributo id. Un id es un identificador único: solo puede haber
un elemento con ese valor en toda la página. El for del label y el id del input
deben coincidir exactamente; así el navegador sabe que están enlazados.
<!-- Forma 1: for en el label, id en el input (los valores deben coincidir) -->
<label for="nombre-heroe">Nombre del héroe</label>
<input id="nombre-heroe" type="text" />
<!-- Forma 2: el label envuelve al input -->
<label>
Nombre del héroe
<input type="text" />
</label>Las dos son válidas. La primera es más flexible (el label y el input pueden estar
separados en el DOM); la segunda es más compacta. Lo que no funciona es usar solo
un <span> o un <div> con texto, aunque visualmente parezca lo mismo.
Muchos inputs llevan también un atributo placeholder: es un texto de ejemplo que aparece
dentro del campo mientras está vacío y desaparece en cuanto el usuario empieza a escribir.
Sirve para dar una pista del formato esperado, pero no sustituye al <label>: el
placeholder no es accesible como etiqueta y, al desaparecer, el usuario ya no sabe para qué
es el campo si lo deja a medias.
El vínculo hace dos cosas:
- Área de clic ampliada: pulsar en el texto del label enfoca el input. En checkboxes y radios, donde el objetivo de clic es pequeño, esto es fundamental.
- Nombre accesible: el lector de pantalla anuncia “Nombre del héroe, campo de texto requerido”. Sin el vínculo, solo dice “campo de texto”.
Validación nativa: el navegador lo hace gratis#
Antes de que existiera JavaScript, los navegadores ya validaban formularios. Eso no ha cambiado; solo se ha mejorado. Los atributos clave:
<!-- Campo obligatorio: no se puede enviar vacío -->
<input type="text" required />
<!-- Email: valida el formato (algo@dominio.ext) -->
<input type="email" required />
<!-- Rango numérico -->
<input type="number" min="0" max="500" />
<!-- Longitud de texto: mínimo 2 caracteres, máximo 50 -->
<input type="text" minlength="2" maxlength="50" />
<!-- Patrón con expresión regular: solo letras (incluidas las del español) y espacios -->
<input type="text" pattern="[A-Za-záéíóúüñÁÉÍÓÚÜÑ\s]+" title="Solo letras y espacios" />minlength y maxlength controlan la longitud en caracteres: minlength="2" impide enviar
si el texto tiene menos de 2 caracteres; maxlength="50" impide escribir más de 50 (el campo
deja de aceptar teclas al llegar al límite).
Cuando el usuario pulsa el botón de envío, el navegador comprueba todos estos atributos
en orden. Si algo falla, muestra un mensaje de error nativo (localizado al idioma del
sistema) y detiene el envío. El atributo title le dice qué mostrar cuando falla el
pattern. Nada de esto necesita JavaScript. Lo que sí necesita JavaScript es personalizar
el aspecto de esos mensajes o validaciones más complejas (que victorias no supere a partidas,
por ejemplo).
<fieldset> y <legend>: agrupar campos relacionados#
Cuando varios campos forman una pregunta (un grupo de radios, un conjunto de checkboxes),
<fieldset> y <legend> los envuelven con nombre:
<fieldset>
<legend>Rol</legend>
<!-- Los tres radios comparten el mismo name="rol": eso los hace un grupo.
Solo se puede seleccionar uno a la vez dentro del mismo grupo.
El name también es el nombre con el que el dato viaja al servidor al enviar. -->
<label><input type="radio" name="rol" value="tanque" /> Tanque</label>
<label><input type="radio" name="rol" value="daño" /> Daño</label>
<label><input type="radio" name="rol" value="apoyo" /> Apoyo</label>
</fieldset>El atributo name en los radios cumple dos funciones: los radios con el mismo name forman
un grupo (el navegador solo permite seleccionar uno a la vez dentro de ese grupo); y name
es el nombre con el que el dato elegido viaja al servidor cuando se envía el formulario.
Sin el <fieldset>, el lector de pantalla anuncia “Tanque, radio” sin contexto. Con él,
anuncia “Rol, grupo de opciones — Tanque, radio”. El usuario sabe qué está eligiendo.
El borde visual por defecto del <fieldset> suele ser feo; en empresa casi siempre se
neutraliza con CSS (border: none). CSS es el lenguaje que controla el aspecto —lo verás
a partir del capítulo siguiente— pero no hace falta entenderlo ahora: el elemento sigue ahí,
invisible para los ojos, imprescindible para quien lo necesita.
Accesibilidad: un poco más allá del label#
Con labels y tipos correctos ya cubres el 80% de la accesibilidad de formularios. Para el 20% restante, dos atributos que merece la pena conocer:
aria-describedby vincula un campo con un elemento que contiene información de ayuda.
El lector de pantalla la lee después del nombre del campo, sin mezclarla con el label:
<label for="victorias">Victorias</label>
<input id="victorias" type="number" min="0" aria-describedby="victorias-hint" />
<p id="victorias-hint">No puede superar el número de partidas jugadas.</p>autocomplete le dice al navegador (y a los gestores de contraseñas) qué clase de dato
espera cada campo, para que pueda autocompletar correctamente:
<input type="email" autocomplete="email" />
<input type="text" autocomplete="name" />Los valores de autocomplete están estandarizados; los más usados: name, email,
tel, username, current-password, new-password, street-address, postal-code.
Pruébalo#
El formulario de abajo está bien construido. Intenta enviarlo con campos vacíos, con
un email mal formado o con un número de partidas negativo. El navegador avisa. Sin
JavaScript. Después observa el código de la izquierda: cada label tiene su for,
cada input su id, los radios están dentro de un fieldset. Cambia required por
nada en el nombre y comprueba que el formulario ya acepta enviarse sin él.
Verás también atributos class= y un fichero styles.css: son CSS, el lenguaje que
controla el aspecto. Lo aprenderás en el capítulo siguiente; por ahora ignóralos y
céntrate en la estructura del formulario.
Comprueba lo que sabes#
Pregunta 1 de 5
Tu formulario tiene un `<input>` para el email. ¿Qué ventaja real aporta usar `type="email"` frente a `type="text"`?
Tu turno#
Tienes el formulario a medio hacer, aquí mismo. Los TODOs están señalados con comentarios en el HTML. El CSS está dado y no se toca. Tres pasadas: que funcione, que valide bien, que sea accesible del todo. Edita el HTML en el playground; cuando creas que lo tienes, despliega las soluciones y compáralas con la tuya.
Ejercicio · en esta página
Completa el formulario del Team Builder
En starter/index.html tienes el esqueleto de un formulario para añadir un héroe a la plantilla. Tiene TODOs señalados con comentarios. El CSS ya está dado y no debes tocarlo. Resuélvelo en tres pasadas: primero que funcione, luego que valide bien, luego que sea accesible del todo.
Paso 1: Que funcione
- El formulario se ve y se puede enviar.
- Todos los campos tienen texto legible (aunque sea con placeholder en lugar de label).
- Los tipos son todos text: el navegador no valida nada por sí solo.
Paso 2: Que valide bien
- Cada label está asociado a su input con for/id (o envolviendo el input).
- Tipos correctos: email para el correo, number para partidas y victorias.
- Validación nativa: required en los campos obligatorios, min="0" en los numéricos, max en victorias.
- Los radios de rol están dentro de un fieldset con legend.
- Landmarks correctos: header, main, footer.
Paso 3: Que sea accesible del todo
- Skip link al inicio para saltar al formulario con el teclado.
- aria-describedby en cada campo, apuntando a un párrafo con una pista de ayuda.
- autocomplete donde aplica: email en el campo de correo.
- pattern con title descriptivo en el nombre del héroe.
- minlength en el nombre para evitar entradas de un solo carácter.
- Sin una sola línea de JavaScript.
Ver soluciones
<!doctype html>
<!-- SOLUCIÓN OK ─────────────────────────────────────────────────────────────
El formulario se ve bien y se puede enviar.
Limitaciones:
- Los labels no están asociados a sus inputs: un clic en el texto no activa
el campo, y un lector de pantalla no sabe a qué campo corresponde.
- Todos los inputs son type="text": el móvil no cambia el teclado, el
navegador no valida el formato del email ni de los números.
- Sin validación nativa: el formulario acepta un email inválido o victorias
negativas sin protestar.
- Los radios no están agrupados; no hay forma de saber que forman una opción.
──────────────────────────────────────────────────────────────────────────── -->
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Añadir héroe — Team Builder</title>
<link rel="stylesheet" href="../../starter/styles.css" />
</head>
<body>
<div class="page">
<div class="site-header">
<div class="brand">Overwatch <span>Team Builder</span></div>
</div>
<div>
<div class="section-title">Añadir héroe a la plantilla</div>
<form class="hero-form" action="#" method="post">
<div class="field">
<span class="field-label">Nombre del héroe</span>
<input class="field-input" type="text" placeholder="Nombre" />
</div>
<div class="field">
<span class="field-label">Correo de contacto</span>
<input class="field-input" type="text" placeholder="correo@ejemplo.com" />
</div>
<div class="field">
<span class="field-label">Partidas jugadas</span>
<input class="field-input" type="text" placeholder="0" />
</div>
<div class="field">
<span class="field-label">Victorias</span>
<input class="field-input" type="text" placeholder="0" />
</div>
<div class="field">
<span class="field-label">Rol</span>
<div class="radio-group">
<input type="radio" name="rol" value="tanque" />
<span>Tanque</span>
<input type="radio" name="rol" value="daño" />
<span>Daño</span>
<input type="radio" name="rol" value="apoyo" />
<span>Apoyo</span>
</div>
</div>
<div class="field field--check">
<input class="check-input" type="checkbox" />
<span class="check-label">Confirmo que los datos son correctos</span>
</div>
<button class="btn-submit" type="submit">Añadir héroe</button>
</form>
</div>
<div class="site-footer">
<p>Overwatch Team Builder © 2026</p>
</div>
</div>
</body>
</html> Por qué este nivel
- El formulario funciona: se ve bien, los campos están ahí y se puede enviar.
- Sin labels asociados: un clic en "Nombre del héroe" no activa el input; el lector de pantalla solo anuncia "campo de texto" sin decir para qué sirve.
- Todos los tipos son text: el móvil no cambia el teclado para el email, y el navegador acepta "abc" como número de partidas sin protestar.
- Los radios están sueltos; sin fieldset/legend no hay forma de saber que forman una pregunta concreta.
<!doctype html>
<!-- SOLUCIÓN MEJOR ──────────────────────────────────────────────────────────
Misma pantalla que OK, pero ahora el formulario trabaja correctamente.
Qué cambia:
- Landmarks: <header>, <main>, <footer> dan regiones navegables.
- Cada <label> tiene su "for" apuntando al "id" del input: clic en el texto
activa el campo; el lector de pantalla anuncia "Nombre del héroe, campo
de texto" en lugar de solo "campo de texto".
- Tipos correctos: type="email" valida formato de correo, type="number"
muestra teclado numérico en móvil y rechaza letras.
- Validación nativa: required en todos los campos obligatorios, min="0"
en partidas y victorias, max="9999" en victorias.
- Los radios están dentro de un <fieldset>/<legend>: el lector anuncia
"Rol, grupo de opciones" antes de cada radio.
- El checkbox tiene su <label> asociado: clic en el texto marca la casilla.
──────────────────────────────────────────────────────────────────────────── -->
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Añadir héroe — Team Builder</title>
<link rel="stylesheet" href="../../starter/styles.css" />
</head>
<body>
<div class="page">
<header class="site-header">
<h1 class="brand">Overwatch <span>Team Builder</span></h1>
</header>
<main>
<h2 class="section-title">Añadir héroe a la plantilla</h2>
<form class="hero-form" action="#" method="post">
<div class="field">
<label for="nombre" class="field-label">Nombre del héroe</label>
<input
id="nombre"
class="field-input"
type="text"
placeholder="p. ej. Tracer"
required
/>
</div>
<div class="field">
<label for="email" class="field-label">Correo de contacto</label>
<input
id="email"
class="field-input"
type="email"
placeholder="correo@ejemplo.com"
required
/>
</div>
<div class="field">
<label for="partidas" class="field-label">Partidas jugadas</label>
<input
id="partidas"
class="field-input"
type="number"
min="0"
placeholder="0"
required
/>
</div>
<div class="field">
<label for="victorias" class="field-label">Victorias</label>
<input
id="victorias"
class="field-input"
type="number"
min="0"
max="9999"
placeholder="0"
required
/>
</div>
<fieldset class="field-group">
<legend>Rol</legend>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="rol" value="tanque" required />
Tanque
</label>
<label class="radio-option">
<input type="radio" name="rol" value="daño" />
Daño
</label>
<label class="radio-option">
<input type="radio" name="rol" value="apoyo" />
Apoyo
</label>
</div>
</fieldset>
<div class="field field--check">
<input
id="confirmar"
class="check-input"
type="checkbox"
required
/>
<label for="confirmar" class="check-label">
Confirmo que los datos son correctos
</label>
</div>
<button class="btn-submit" type="submit">Añadir héroe</button>
</form>
</main>
<footer class="site-footer">
<p>Overwatch Team Builder © 2026</p>
</footer>
</div>
</body>
</html> Por qué es mejor que el anterior
- Labels asociados con for/id: el clic en el texto activa el campo, y el lector de pantalla anuncia "Nombre del héroe, campo de texto requerido".
- type="email" valida el formato del correo antes de enviar y muestra el teclado especializado en móvil. type="number" rechaza letras y aplica min/max.
- required, min="0" y max="9999" actúan como validadores nativos: el navegador avisa si el formulario no cumple antes de enviarlo. Cero JavaScript.
- fieldset y legend agrupan los radios: el lector anuncia "Rol, grupo de opciones" antes de cada opción, dando el contexto que faltaba.
<!doctype html>
<!-- SOLUCIÓN EXCELENTE ──────────────────────────────────────────────────────
Parte de lo de "Mejor" y añade lo que marca la diferencia en un entorno real.
Qué cambia:
- Skip link: quien navega con el teclado puede saltar el encabezado e ir
directo al formulario. El CSS del starter ya lo posiciona correctamente
cuando recibe el foco.
- autocomplete en todos los campos donde aplica: el navegador puede rellenar
automáticamente y los gestores de contraseñas saben qué campo es qué.
- pattern en el nombre: solo letras, espacios y guiones (sin números). El
atributo title explica la restricción al usuario si el patrón falla.
- minlength en el nombre: evita entradas de un solo carácter accidentales.
- aria-describedby en cada campo apunta a un <p> con una pista de ayuda:
el lector de pantalla la lee después del nombre del campo, sin mezclar
label y descripción en el mismo texto.
- El <main> lleva id="contenido" para que el skip link funcione.
- Ninguna de estas mejoras necesita una línea de JavaScript: el navegador
hace todo el trabajo. Así de potente es el HTML bien escrito.
──────────────────────────────────────────────────────────────────────────── -->
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Añadir héroe — Team Builder</title>
<link rel="stylesheet" href="../../starter/styles.css" />
</head>
<body>
<a class="skip-link" href="#contenido">Saltar al contenido</a>
<div class="page">
<header class="site-header">
<h1 class="brand">Overwatch <span>Team Builder</span></h1>
</header>
<main id="contenido">
<h2 class="section-title">Añadir héroe a la plantilla</h2>
<form class="hero-form" action="#" method="post">
<div class="field">
<label for="nombre" class="field-label">Nombre del héroe</label>
<input
id="nombre"
class="field-input"
type="text"
autocomplete="off"
placeholder="p. ej. Tracer"
required
minlength="2"
pattern="[A-Za-záéíóúüñÁÉÍÓÚÜÑ\s\-]+"
title="Solo letras, espacios y guiones"
aria-describedby="nombre-hint"
/>
<p id="nombre-hint" class="field-hint">
Nombre del héroe tal como aparece en el juego.
</p>
</div>
<div class="field">
<label for="email" class="field-label">Correo de contacto</label>
<input
id="email"
class="field-input"
type="email"
autocomplete="email"
placeholder="correo@ejemplo.com"
required
aria-describedby="email-hint"
/>
<p id="email-hint" class="field-hint">
Se usará para notificarte cambios en la plantilla.
</p>
</div>
<div class="field">
<label for="partidas" class="field-label">Partidas jugadas</label>
<input
id="partidas"
class="field-input"
type="number"
autocomplete="off"
min="0"
placeholder="0"
required
aria-describedby="partidas-hint"
/>
<p id="partidas-hint" class="field-hint">
Total de partidas con este héroe en la temporada actual.
</p>
</div>
<div class="field">
<label for="victorias" class="field-label">Victorias</label>
<input
id="victorias"
class="field-input"
type="number"
autocomplete="off"
min="0"
max="9999"
placeholder="0"
required
aria-describedby="victorias-hint"
/>
<p id="victorias-hint" class="field-hint">
No puede superar el número de partidas jugadas.
</p>
</div>
<fieldset class="field-group" aria-describedby="rol-hint">
<legend>Rol</legend>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="rol" value="tanque" required />
Tanque
</label>
<label class="radio-option">
<input type="radio" name="rol" value="daño" />
Daño
</label>
<label class="radio-option">
<input type="radio" name="rol" value="apoyo" />
Apoyo
</label>
</div>
<p id="rol-hint" class="field-hint">
Selecciona el rol principal del héroe.
</p>
</fieldset>
<div class="field field--check">
<input
id="confirmar"
class="check-input"
type="checkbox"
required
aria-describedby="confirmar-hint"
/>
<label for="confirmar" class="check-label">
Confirmo que los datos son correctos
</label>
</div>
<p id="confirmar-hint" class="field-hint">
Los datos se añadirán a la plantilla del equipo.
</p>
<button class="btn-submit" type="submit">Añadir héroe</button>
</form>
</main>
<footer class="site-footer">
<p>Overwatch Team Builder © 2026</p>
</footer>
</div>
</body>
</html> Por qué es mejor que el anterior
- Skip link: quien navega solo con el teclado puede saltar la cabecera e ir directo al formulario. El CSS del starter ya lo posiciona; solo hay que escribir el elemento.
- aria-describedby vincula cada campo con un párrafo de ayuda. El lector de pantalla lee el label y después la descripción: dos capas de información sin mezclarlas.
- autocomplete="email" permite al navegador y a los gestores de contraseñas autocompletar correctamente. pattern con title descriptivo explica la restricción al usuario cuando el valor no pasa la validación.
- El resultado es un formulario totalmente operativo sin una línea de JavaScript: validación, accesibilidad, autocompletado. Esto es lo que el navegador te da gratis cuando le pides lo correcto.