Un formulario de contacto es uno de los componentes más comunes en cualquier sitio web. Parece simple, pero hacerlo bien implica más que poner unos campos y un botón: necesitas estructura semántica en HTML, estilos que guíen al usuario, y validación en JavaScript que le dé retroalimentación inmediata antes de enviar los datos.
En esta guía construirás un formulario de contacto completo desde cero, paso a paso, entendiendo qué hace cada parte y por qué.
Lo que vas a construir
Al final del artículo tendrás un formulario con los siguientes campos:
- Nombre completo (texto obligatorio)
- Correo electrónico (formato válido obligatorio)
- Asunto (texto obligatorio)
- Mensaje (área de texto obligatoria, mínimo 20 caracteres)
- Botón de envío con estado de carga
El formulario tendrá validación en tiempo real que muestra errores debajo de cada campo mientras el usuario escribe, y un mensaje de éxito al enviarlo correctamente.
Paso 1: La estructura HTML
Empieza por el HTML semántico. Cada campo debe tener su <label> asociado, sus atributos de accesibilidad y los atributos nativos de HTML5 que le indican al navegador qué tipo de dato espera.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Formulario de contacto</title>
<link rel="stylesheet" href="estilos.css">
</head>
<body>
<main class="pagina">
<section class="formulario-contenedor">
<h1>Contáctanos</h1>
<p class="subtitulo">Responderemos en menos de 24 horas.</p>
<form id="formulario-contacto" novalidate>
<!-- Nombre -->
<div class="campo">
<label for="nombre">Nombre completo</label>
<input
type="text"
id="nombre"
name="nombre"
placeholder="Ej. Ana García"
autocomplete="name"
aria-describedby="nombre-error"
>
<span class="error" id="nombre-error" role="alert"></span>
</div>
<!-- Email -->
<div class="campo">
<label for="email">Correo electrónico</label>
<input
type="email"
id="email"
name="email"
placeholder="Ej. ana@ejemplo.com"
autocomplete="email"
aria-describedby="email-error"
>
<span class="error" id="email-error" role="alert"></span>
</div>
<!-- Asunto -->
<div class="campo">
<label for="asunto">Asunto</label>
<input
type="text"
id="asunto"
name="asunto"
placeholder="¿En qué podemos ayudarte?"
aria-describedby="asunto-error"
>
<span class="error" id="asunto-error" role="alert"></span>
</div>
<!-- Mensaje -->
<div class="campo">
<label for="mensaje">Mensaje</label>
<textarea
id="mensaje"
name="mensaje"
rows="5"
placeholder="Escribe tu mensaje aquí..."
aria-describedby="mensaje-error"
></textarea>
<span class="contador" id="contador-mensaje">0 / 20 caracteres mínimo</span>
<span class="error" id="mensaje-error" role="alert"></span>
</div>
<!-- Botón -->
<button type="submit" id="btn-enviar">Enviar mensaje</button>
</form>
<!-- Mensaje de éxito (oculto por defecto) -->
<div class="exito" id="mensaje-exito" hidden>
<strong>¡Mensaje enviado!</strong>
<p>Gracias por escribirnos. Te responderemos pronto.</p>
</div>
</section>
</main>
<script src="formulario.js"></script>
</body>
</html>
Algunos detalles importantes de este HTML:
novalidateen el<form>desactiva la validación nativa del navegador para que podamos controlarla nosotros mismos con JavaScript y mostrar mensajes más personalizados.- Cada
<input>tiene unidque coincide con elforde su<label>. Esto los vincula semánticamente: al hacer clic en el label, el foco va al campo. También es esencial para los lectores de pantalla. aria-describedbyasocia cada campo con su mensaje de error, para que los lectores de pantalla anuncien el error cuando el campo está enfocado.role="alert"en los spans de error hace que los lectores de pantalla anuncien el mensaje automáticamente cuando aparece.autocompleteayuda al navegador a rellenar campos automáticamente, mejorando la experiencia de usuario.
Paso 2: Los estilos CSS
El CSS tiene dos objetivos: hacer el formulario visualmente claro y dar retroalimentación visual al usuario según el estado de cada campo (neutral, error, válido).
/* estilos.css */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 16px;
line-height: 1.6;
background-color: #f5f5f5;
color: #1a1a1a;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
/* Contenedor principal */
.pagina {
width: 100%;
}
.formulario-contenedor {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2.5rem 2rem;
width: 100%;
max-width: 540px;
margin: 0 auto;
}
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
color: #111;
}
.subtitulo {
color: #666;
margin-bottom: 2rem;
font-size: 0.95rem;
}
/* Campos del formulario */
.campo {
display: flex;
flex-direction: column;
margin-bottom: 1.25rem;
}
label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.4rem;
color: #333;
}
input,
textarea {
width: 100%;
padding: 0.65rem 0.85rem;
border: 1.5px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
color: #1a1a1a;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
input::placeholder,
textarea::placeholder {
color: #aaa;
}
input:focus,
textarea:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
}
textarea {
resize: vertical;
min-height: 120px;
}
/* Estados de validación */
input.valido,
textarea.valido {
border-color: #16a34a;
}
input.invalido,
textarea.invalido {
border-color: #dc2626;
background-color: #fff8f8;
}
input.invalido:focus,
textarea.invalido:focus {
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12);
}
/* Mensajes de error */
.error {
font-size: 0.8rem;
color: #dc2626;
margin-top: 0.3rem;
min-height: 1.1rem;
display: block;
}
/* Contador de caracteres */
.contador {
font-size: 0.78rem;
color: #888;
margin-top: 0.3rem;
text-align: right;
}
.contador.suficiente {
color: #16a34a;
}
/* Botón de envío */
button[type="submit"] {
width: 100%;
padding: 0.8rem;
background-color: #4f46e5;
color: #fff;
font-size: 1rem;
font-weight: 600;
font-family: inherit;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
margin-top: 0.5rem;
}
button[type="submit"]:hover {
background-color: #4338ca;
}
button[type="submit"]:active {
transform: scale(0.99);
}
button[type="submit"]:disabled {
background-color: #a5b4fc;
cursor: not-allowed;
transform: none;
}
/* Mensaje de éxito */
.exito {
text-align: center;
padding: 2rem;
border-radius: 8px;
background: #f0fdf4;
border: 1.5px solid #bbf7d0;
margin-top: 1.5rem;
}
.exito strong {
display: block;
font-size: 1.2rem;
color: #15803d;
margin-bottom: 0.5rem;
}
.exito p {
color: #166534;
font-size: 0.95rem;
}
/* Responsivo */
@media (max-width: 480px) {
.formulario-contenedor {
padding: 1.75rem 1.25rem;
border-radius: 8px;
}
h1 {
font-size: 1.5rem;
}
}
Los puntos clave del CSS:
- Las clases
.validoe.invalidose añaden y eliminan desde JavaScript según el estado de cada campo. Así el CSS solo define el aspecto visual y JavaScript controla cuándo aplicarlo. - El estado
:focustiene un anillo de color (box-shadow) que indica claramente qué campo está activo. Nunca elimines el outline sin reemplazarlo por algo equivalente, es esencial para la accesibilidad con teclado. transitionen los campos hace que los cambios de borde sean suaves en lugar de bruscos.- El botón desactivado (
:disabled) tiene un color diferente y elimina el cursor de pointer para que el usuario sepa que no puede hacer clic.
Paso 3: La validación con JavaScript
El JavaScript se encarga de tres cosas: validar cada campo cuando el usuario sale de él (evento blur), actualizar el contador de caracteres del mensaje en tiempo real (evento input), y validar todo el formulario al enviarlo (evento submit).
// formulario.js
const formulario = document.getElementById("formulario-contacto");
const btnEnviar = document.getElementById("btn-enviar");
const msgExito = document.getElementById("mensaje-exito");
// Referencias a los campos y sus mensajes de error
const campos = {
nombre: { input: document.getElementById("nombre"), error: document.getElementById("nombre-error") },
email: { input: document.getElementById("email"), error: document.getElementById("email-error") },
asunto: { input: document.getElementById("asunto"), error: document.getElementById("asunto-error") },
mensaje: { input: document.getElementById("mensaje"), error: document.getElementById("mensaje-error") },
};
const contadorMensaje = document.getElementById("contador-mensaje");
// ── Funciones de validación ──────────────────────────────────────────────────
function validarNombre(valor) {
if (!valor.trim()) return "El nombre es obligatorio.";
if (valor.trim().length < 2) return "El nombre debe tener al menos 2 caracteres.";
return "";
}
function validarEmail(valor) {
if (!valor.trim()) return "El correo electrónico es obligatorio.";
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!regex.test(valor)) return "Introduce un correo electrónico válido.";
return "";
}
function validarAsunto(valor) {
if (!valor.trim()) return "El asunto es obligatorio.";
if (valor.trim().length < 3) return "El asunto debe tener al menos 3 caracteres.";
return "";
}
function validarMensaje(valor) {
if (!valor.trim()) return "El mensaje es obligatorio.";
if (valor.trim().length < 20) return `Faltan ${20 - valor.trim().length} caracteres más.`;
return "";
}
// Mapa de validadores por nombre de campo
const validadores = {
nombre: validarNombre,
email: validarEmail,
asunto: validarAsunto,
mensaje: validarMensaje,
};
// ── Mostrar / limpiar errores ────────────────────────────────────────────────
function mostrarError(campo, mensaje) {
campo.input.classList.remove("valido");
campo.input.classList.add("invalido");
campo.error.textContent = mensaje;
}
function mostrarValido(campo) {
campo.input.classList.remove("invalido");
campo.input.classList.add("valido");
campo.error.textContent = "";
}
function limpiarEstado(campo) {
campo.input.classList.remove("valido", "invalido");
campo.error.textContent = "";
}
// ── Validar un campo individual ──────────────────────────────────────────────
function validarCampo(nombre) {
const campo = campos[nombre];
const valor = campo.input.value;
const error = validadores[nombre](valor);
if (error) {
mostrarError(campo, error);
return false;
} else {
mostrarValido(campo);
return true;
}
}
// ── Contador de caracteres del mensaje ──────────────────────────────────────
campos.mensaje.input.addEventListener("input", () => {
const longitud = campos.mensaje.input.value.trim().length;
const minimo = 20;
if (longitud >= minimo) {
contadorMensaje.textContent = `${longitud} caracteres ✓`;
contadorMensaje.classList.add("suficiente");
} else {
contadorMensaje.textContent = `${longitud} / ${minimo} caracteres mínimo`;
contadorMensaje.classList.remove("suficiente");
}
});
// ── Validación al salir de cada campo (blur) ─────────────────────────────────
Object.keys(campos).forEach(nombre => {
campos[nombre].input.addEventListener("blur", () => {
// Solo validar si el campo fue tocado (tiene valor o ya tuvo foco)
if (campos[nombre].input.value !== "") {
validarCampo(nombre);
}
});
// Limpiar el error si el usuario empieza a corregir
campos[nombre].input.addEventListener("input", () => {
if (campos[nombre].input.classList.contains("invalido")) {
validarCampo(nombre);
}
});
});
// ── Envío del formulario ─────────────────────────────────────────────────────
formulario.addEventListener("submit", async (evento) => {
evento.preventDefault(); // evitar la recarga de página
// Validar todos los campos
const resultados = Object.keys(campos).map(nombre => validarCampo(nombre));
const todoValido = resultados.every(r => r === true);
if (!todoValido) {
// Mover el foco al primer campo inválido
const primerInvalido = Object.values(campos).find(c => c.input.classList.contains("invalido"));
if (primerInvalido) primerInvalido.input.focus();
return;
}
// Simular envío (aquí conectarías con tu backend o servicio de email)
btnEnviar.disabled = true;
btnEnviar.textContent = "Enviando...";
try {
await simularEnvio({
nombre: campos.nombre.input.value.trim(),
email: campos.email.input.value.trim(),
asunto: campos.asunto.input.value.trim(),
mensaje: campos.mensaje.input.value.trim(),
});
// Mostrar éxito
formulario.hidden = true;
msgExito.hidden = false;
} catch (error) {
alert("Hubo un error al enviar el mensaje. Por favor, inténtalo de nuevo.");
btnEnviar.disabled = false;
btnEnviar.textContent = "Enviar mensaje";
}
});
// ── Simulación de envío asíncrono ────────────────────────────────────────────
function simularEnvio(datos) {
return new Promise((resolve, reject) => {
console.log("Datos a enviar:", datos);
// Simula 1.5 segundos de espera (como si llamara a una API)
setTimeout(resolve, 1500);
// Para probar el error, cambia resolve por reject:
// setTimeout(reject, 1500);
});
}
Lo que hace cada parte del JavaScript:
- Funciones de validación puras: cada función recibe un valor y devuelve un mensaje de error o una cadena vacía si es válido. Son fáciles de testear y de modificar de forma independiente.
- Validación en
blur: se activa cuando el usuario sale del campo. No molesta mientras el usuario está escribiendo por primera vez, pero sí muestra el error en cuanto abandona el campo sin completarlo correctamente. - Corrección en tiempo real: una vez que un campo muestra un error, el evento
inputlo revalida en cada pulsación para dar retroalimentación inmediata mientras el usuario corrige. - Envío con
async/await: el formulario simula una llamada asíncrona a una API. El botón se desactiva mientras se envía para evitar envíos duplicados. - Accesibilidad al enviar: si hay campos inválidos, el foco se mueve automáticamente al primero de ellos para que el usuario sepa dónde está el problema.
Paso 4: Conectar con un backend real
La función simularEnvio() es el único punto que debes reemplazar para que el formulario funcione en producción. Dependiendo de tu stack, tienes varias opciones.
Opción A: Enviar a tu propia API con Fetch
async function enviarFormulario(datos) {
const respuesta = await fetch("https://tu-api.com/contacto", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(datos),
});
if (!respuesta.ok) {
throw new Error(`Error del servidor: ${respuesta.status}`);
}
return respuesta.json();
}
Opción B: Usar un servicio de formularios externo
Si no tienes backend propio, servicios como Formspree, Web3Forms o EmailJS te permiten enviar formularios a una dirección de correo sin escribir ningún código del lado del servidor. Solo cambia la URL del fetch por la que te proporciona el servicio y añade los campos que requiera.
// Ejemplo con Formspree
async function enviarConFormspree(datos) {
const respuesta = await fetch("https://formspree.io/f/TU_ID_AQUI", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(datos),
});
if (!respuesta.ok) throw new Error("Error al enviar");
return respuesta.json();
}
Opción C: Backend en Node.js con Nodemailer
// servidor.js (Node.js + Express)
const express = require("express");
const nodemailer = require("nodemailer");
const app = express();
app.use(express.json());
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS, // usa una contraseña de aplicación de Google
},
});
app.post("/contacto", async (req, res) => {
const { nombre, email, asunto, mensaje } = req.body;
try {
await transporter.sendMail({
from: email,
to: "tu@email.com",
subject: `[Contacto] ${asunto}`,
text: `De: ${nombre} <${email}>\n\n${mensaje}`,
});
res.json({ ok: true });
} catch (error) {
res.status(500).json({ error: "Error al enviar el correo." });
}
});
app.listen(3000, () => console.log("Servidor en puerto 3000"));
Paso 5: Mejoras adicionales
El formulario que has construido es funcional y accesible. Estas son algunas mejoras que puedes añadir dependiendo de tus necesidades.
Protección anti-spam con honeypot
Un honeypot es un campo oculto para el usuario pero visible para los bots. Si llega relleno, sabes que es spam y puedes ignorarlo en el servidor.
<!-- En el HTML: campo trampa oculto para bots -->
<div style="position:absolute; left:-9999px; opacity:0;" aria-hidden="true">
<label for="empresa">Empresa (no rellenar)</label>
<input type="text" id="empresa" name="empresa" tabindex="-1" autocomplete="off">
</div>
// En JavaScript: comprobar antes de enviar
const honeypot = document.getElementById("empresa");
if (honeypot.value !== "") {
console.log("Bot detectado, formulario ignorado.");
return;
}
Guardar el progreso en localStorage
Si el usuario cierra la pestaña por accidente, puedes recuperar lo que había escrito:
// Guardar al escribir
Object.keys(campos).forEach(nombre => {
campos[nombre].input.addEventListener("input", () => {
localStorage.setItem(`form_${nombre}`, campos[nombre].input.value);
});
});
// Restaurar al cargar la página
window.addEventListener("DOMContentLoaded", () => {
Object.keys(campos).forEach(nombre => {
const guardado = localStorage.getItem(`form_${nombre}`);
if (guardado) campos[nombre].input.value = guardado;
});
});
// Limpiar localStorage al enviar con éxito
function limpiarStorage() {
Object.keys(campos).forEach(nombre => localStorage.removeItem(`form_${nombre}`));
}
Indicador de fortaleza de campos opcionales
Si añadieras un campo de contraseña (por ejemplo en un formulario de registro), podrías mostrar visualmente qué tan segura es mientras el usuario escribe:
function evaluarFortaleza(valor) {
let puntos = 0;
if (valor.length >= 8) puntos++;
if (/[A-Z]/.test(valor)) puntos++;
if (/[0-9]/.test(valor)) puntos++;
if (/[^A-Za-z0-9]/.test(valor)) puntos++;
const niveles = ["Muy débil", "Débil", "Media", "Fuerte"];
return { nivel: niveles[puntos - 1] || "Muy débil", puntos };
}
Resumen
Has construido un formulario de contacto completo con tres capas bien separadas:
- HTML semántico: etiquetas correctas, atributos de accesibilidad (
aria-describedby,role="alert"),autocompleteynovalidatepara tomar el control de la validación. - CSS con estados: estilos visuales para los estados neutro, válido e inválido, transiciones suaves, feedback de foco accesible y diseño responsivo.
- JavaScript modular: funciones de validación puras, validación en
blurpara no molestar mientras se escribe, corrección en tiempo real, envío asíncrono conasync/awaity protección contra envíos duplicados.
Esta separación de responsabilidades hace que el formulario sea fácil de mantener: puedes cambiar el aspecto visual tocando solo el CSS, ajustar las reglas de validación en las funciones de JavaScript sin tocar el HTML, y cambiar el destino del envío reemplazando únicamente la función de envío.
No hay comentarios todavía. Sé el primero en compartir tu opinión.