Cómo crear un formulario de contacto con HTML, CSS y JavaScript

D
DanisCh
(Actualizado: ) • 14 min de lectura
Cómo crear un formulario de contacto con HTML, CSS y JavaScript
JavaScript HTML

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:

  • novalidate en 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 un id que coincide con el for de 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-describedby asocia 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.
  • autocomplete ayuda 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 .valido e .invalido se 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 :focus tiene 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.
  • transition en 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 input lo 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"), autocomplete y novalidate para 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 blur para no molestar mientras se escribe, corrección en tiempo real, envío asíncrono con async/await y 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.

Etiquetas: JavaScript CSS HTML

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a