Contacto neumorfismo con validación
Formulario validado campo por campo, errores junto a cada campo, foco al primero inválido y pantalla de confirmación.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useId, useState, type CSSProperties, type FormEvent } from "react";
/** Sección de contacto con formulario validado campo por campo, errores junto a cada campo, enfoque al primero inválido y pantalla de confirmación. Neumorfismo. React + TypeScript, sin dependencias. */
export type SeccionContactoProps = {
title?: string;
email?: string;
onSubmit?: (data: { name: string; email: string; message: string }) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsco-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(24px, 5cqi, 64px) clamp(16px, 4cqi, 56px); background: #e0e5ec; color: #46506a; font: 600 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcsco-root *, .gcsco-root *::before, .gcsco-root *::after { box-sizing: border-box; }
.gcsco-root input, .gcsco-root textarea, .gcsco-root button { font: inherit; }
.gcsco-root :focus-visible { outline: 3px solid #5b8def; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .gcsco-root * { animation: none !important; transition-duration: .01ms !important; } }
.gcsco-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 44px; align-items: start; max-width: 960px; margin: 0 auto; }
.gcsco-title { margin: 0 0 12px; color: #3b4658; font: 800 clamp(30px, 4.6cqi, 52px)/1.04 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.04em; text-wrap: balance; }
.gcsco-info { margin: 0 0 22px; max-width: 32ch; }
.gcsco-mail { display: inline-flex; padding: 12px 18px; border-radius: 14px; color: #3b4658; text-decoration: none; box-shadow: 5px 5px 10px #a3b1c6, -5px -5px 10px #fff; transition: box-shadow .15s; }
.gcsco-mail:hover { box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; }
.gcsco-form { display: grid; gap: 18px; padding: 28px; border-radius: 28px; box-shadow: 10px 10px 20px #a3b1c6, -10px -10px 20px #fff; }
.gcsco-f label { display: block; margin: 0 0 8px 6px; font-size: 13px; letter-spacing: .04em; }
.gcsco-in { width: 100%; min-height: 50px; padding: 12px 16px; border: 0; border-radius: 14px; background: #e0e5ec; color: #3b4658; box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; }
.gcsco-in::placeholder { color: #98a3b8; }
textarea.gcsco-in { min-height: 120px; resize: vertical; }
.gcsco-in[aria-invalid="true"] { box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff, 0 0 0 2px #d6324a; }
.gcsco-err { min-height: 18px; margin: 6px 0 0 6px; font-size: 13px; color: #c2263a; }
.gcsco-send { min-height: 54px; border: 0; border-radius: 16px; background: #e0e5ec; color: #3b4658; font-weight: 800; cursor: pointer; box-shadow: 6px 6px 12px #a3b1c6, -6px -6px 12px #fff; transition: box-shadow .15s; }
.gcsco-send:hover { color: #5b8def; }
.gcsco-send:active { box-shadow: inset 4px 4px 8px #a3b1c6, inset -4px -4px 8px #fff; }
.gcsco-done { display: grid; gap: 10px; justify-items: center; padding: 40px 20px; text-align: center; border-radius: 28px; box-shadow: 10px 10px 20px #a3b1c6, -10px -10px 20px #fff; animation: gcsco-in .5s cubic-bezier(.16, 1, .3, 1); }
.gcsco-done svg { padding: 16px; border-radius: 50%; color: #1f9d6b; box-shadow: inset 5px 5px 10px #a3b1c6, inset -5px -5px 10px #fff; }
.gcsco-done h3 { margin: 0; color: #3b4658; font-size: 24px; }
.gcsco-redo { margin-top: 8px; padding: 10px 18px; border: 0; border-radius: 12px; background: #e0e5ec; color: inherit; cursor: pointer; box-shadow: 4px 4px 8px #a3b1c6, -4px -4px 8px #fff; }
@keyframes gcsco-in { from { opacity: 0; transform: translateY(10px) scale(.97); } }
@container (max-width: 720px) { .gcsco-grid { grid-template-columns: minmax(0, 1fr); gap: 28px; } }`;
export function SeccionContacto({ title = "Hablemos de tu proyecto", email = "hola@ejemplo.com", onSubmit, className = "", style }: SeccionContactoProps) {
const id = useId();
const [errors, setErrors] = useState<Record<string, string>>({});
const [done, setDone] = useState(false);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const name = String(data.get("name") ?? "").trim();
const mail = String(data.get("email") ?? "").trim();
const message = String(data.get("message") ?? "").trim();
const next: Record<string, string> = {};
if (name.length < 2) next.name = "Escribe tu nombre.";
if (!/^\S+@\S+\.\S+$/.test(mail)) next.email = "Escribe un correo válido, por ejemplo nombre@dominio.com.";
if (message.length < 10) next.message = "Cuéntanos un poco más (mínimo 10 caracteres).";
setErrors(next);
const first = Object.keys(next)[0];
if (first) { (form.elements.namedItem(first) as HTMLElement | null)?.focus(); return; }
onSubmit?.({ name, email: mail, message });
setDone(true);
}
const field = (key: string, label: string, el: React.ReactNode) => (
<div className="gcsco-f">
<label htmlFor={`${id}-${key}`}>{label}</label>
{el}
<p className="gcsco-err" id={`${id}-${key}-e`} role="alert">{errors[key] ?? ""}</p>
</div>
);
return (
<section className={`gcsco-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gcsco-grid">
<div>
<h2 className="gcsco-title">{title}</h2>
<p className="gcsco-info">Cuéntanos qué quieres resolver. Respondemos en un día hábil.</p>
<a className="gcsco-mail" href={`mailto:${email}`}>{email}</a>
</div>
{done ? (
<div className="gcsco-done" role="status">
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5" /></svg>
<h3>Mensaje enviado</h3>
<p>Gracias. Te responderemos pronto.</p>
<button className="gcsco-redo" type="button" onClick={() => setDone(false)}>Enviar otro</button>
</div>
) : (
<form className="gcsco-form" onSubmit={submit} noValidate>
{field("name", "Nombre", <input id={`${id}-name`} name="name" className="gcsco-in" placeholder="Tu nombre" autoComplete="name" aria-invalid={!!errors.name} aria-describedby={`${id}-name-e`} />)}
{field("email", "Correo", <input id={`${id}-email`} name="email" type="email" className="gcsco-in" placeholder="tu@correo.com" autoComplete="email" aria-invalid={!!errors.email} aria-describedby={`${id}-email-e`} />)}
{field("message", "Mensaje", <textarea id={`${id}-message`} name="message" className="gcsco-in" placeholder="¿Qué quieres resolver?" aria-invalid={!!errors.message} aria-describedby={`${id}-message-e`} />)}
<button className="gcsco-send" type="submit">Enviar mensaje</button>
</form>
)}
</div>
</section>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/seccion-contactoEjemplo de uso de Contacto neumorfismo con validación
Después de copiar seccion-contacto.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { SeccionContacto } from "./seccion-contacto";
export default function Ejemplo() {
return <SeccionContacto title="Ejemplo" />;
}Propiedades de SeccionContacto
SeccionContacto acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
title? | string | Opcional. |
email? | string | Opcional. |
onSubmit? | (data: { name: string; email: string; message: string }) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 100 líneas (7 KB) y define 1 animación con @keyframes. Esto es lo que hace Contacto neumorfismo con validación por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: alert, status.
- Es un componente de cliente de Next.js: incluye la directiva "use client".