Ir al contenido
Evaluar mi proyecto
Componentes · SeccionesDisponible

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.

Hablemos de tu proyecto

Cuéntanos qué quieres resolver. Respondemos en un día hábil.

hola@ejemplo.com

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

seccion-contacto.tsx100 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/seccion-contacto

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
title?stringOpcional.
email?stringOpcional.
onSubmit?(data: { name: string; email: string; message: string }) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)