Skip to content
goodtabs UI
BrowseBrowse components

Contact section

A contact form with labelled fields, inline validation and pending and success states.

npx shadcn@latest add https://ui.goodtabs.co/r/contact-section.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/contact-section

Contact

Talk to us

Tell us what you are building. A person replies.

Validates on blur and on submit. Nothing is sent.

Pending then success

Report a problem

onSubmit returns a promise, so the button spins before the thank you.

Failing submit

Send a note

onSubmit rejects, so a retry message shows and the values stay.

Usage

import { ContactSection } from "@/components/contact-section"

<ContactSection title="Talk to us" onSubmit={async (v) => { await send(v) }} />

Props

PropTypeDefaultDescription
eyebrowstringSmall uppercase line above the title.
titleReactNodeThe section heading.
descriptionReactNodeOne or two sentences under the title.
onSubmit(values: { name: string; email: string; message: string }) => void | Promise<void>Called with trimmed values once valid. A rejection shows a retry message. No network call is made by default.
buttonLabelstring"Send message"Label of the submit button.
successTextstringShown once the submit settles.
minMessagenumber20Minimum message length in characters.
size"sm" | "default""default"Spacing scale. sm fits a card.

Source

contact-section/contact-section.tsx
"use client";

import * as React from "react";
import { CheckIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";

export type ContactValues = { name: string; email: string; message: string };

type ContactSectionProps = Omit<React.ComponentProps<"section">, "title" | "onSubmit"> & {
  /** Small uppercase line above the title. */
  eyebrow?: string;
  /** The section heading. */
  title?: React.ReactNode;
  /** One or two sentences under the title. */
  description?: React.ReactNode;
  /** Called with the trimmed values once they pass. Return a promise to show a pending state. Nothing is sent by default. */
  onSubmit?: (values: ContactValues) => void | Promise<void>;
  /** Label of the submit button. */
  buttonLabel?: string;
  /** Shown once the submit settles. */
  successText?: string;
  /** Minimum message length in characters. */
  minMessage?: number;
  /** Spacing scale. "sm" fits a card or narrow column. */
  size?: "sm" | "default";
};

type Errors = Partial<Record<keyof ContactValues, string>>;

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function validate(v: ContactValues, minMessage: number): Errors {
  const e: Errors = {};
  if (!v.name.trim()) e.name = "Enter your name";
  if (!v.email.trim()) e.email = "Enter your email address";
  else if (!EMAIL.test(v.email.trim())) e.email = "Enter a valid email address";
  if (!v.message.trim()) e.message = "Write a message";
  else if (v.message.trim().length < minMessage) e.message = `Write at least ${minMessage} characters`;
  return e;
}

function Field({
  id,
  label,
  error,
  children,
}: {
  id: string;
  label: string;
  error?: string;
  children: React.ReactNode;
}) {
  return (
    <div className="flex flex-col gap-1.5">
      <Label htmlFor={id}>{label}</Label>
      {children}
      <p id={`${id}-error`} aria-live="polite" className={cn("m-0 text-xs text-destructive", !error && "sr-only")}>
        {error ?? ""}
      </p>
    </div>
  );
}

/**
 * A contact section: heading and a form with name, email and message.
 * Validates on blur and again on submit, shows pending and success states, and never makes a network call itself.
 */
export function ContactSection({
  eyebrow,
  title,
  description,
  onSubmit,
  buttonLabel = "Send message",
  successText = "Thanks, we will reply within two working days.",
  minMessage = 20,
  size = "default",
  className,
  ...props
}: ContactSectionProps) {
  const uid = React.useId();
  const [values, setValues] = React.useState<ContactValues>({ name: "", email: "", message: "" });
  const [touched, setTouched] = React.useState<Partial<Record<keyof ContactValues, boolean>>>({});
  const [status, setStatus] = React.useState<"idle" | "pending" | "done">("idle");
  const [failed, setFailed] = React.useState(false);
  const sm = size === "sm";

  const errors = validate(values, minMessage);
  const shown = (k: keyof ContactValues) => (touched[k] ? errors[k] : undefined);

  const bind = (k: keyof ContactValues) => ({
    id: `${uid}-${k}`,
    name: k,
    value: values[k],
    disabled: status === "pending",
    "aria-invalid": shown(k) ? true : undefined,
    "aria-describedby": shown(k) ? `${uid}-${k}-error` : undefined,
    onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
      setValues((v) => ({ ...v, [k]: e.target.value }));
      if (failed) setFailed(false);
    },
    onBlur: () => setTouched((t) => ({ ...t, [k]: true })),
  });

  const submit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setTouched({ name: true, email: true, message: true });
    const first = (Object.keys(errors) as (keyof ContactValues)[])[0];
    if (first) {
      (e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus();
      return;
    }
    try {
      setStatus("pending");
      await onSubmit?.({
        name: values.name.trim(),
        email: values.email.trim(),
        message: values.message.trim(),
      });
      setStatus("done");
    } catch {
      setStatus("idle");
      setFailed(true);
    }
  };

  return (
    <section className={cn(sm ? "py-8" : "py-16 lg:py-24", className)} {...props}>
      <div className="mx-auto w-full max-w-xl px-4 md:px-6">
        {(eyebrow || title || description) && (
          <header className={cn("flex flex-col gap-2", sm ? "mb-6" : "mb-8")}>
            {eyebrow && (
              <p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">{eyebrow}</p>
            )}
            {title && (
              <h2 className={cn("m-0 font-medium tracking-tight text-balance", sm ? "text-xl" : "text-3xl")}>
                {title}
              </h2>
            )}
            {description && <p className="m-0 text-sm text-muted-foreground sm:text-base">{description}</p>}
          </header>
        )}
        {status === "done" ? (
          <div role="status" className="flex items-center gap-2 rounded-xl border p-4 text-sm font-medium">
            <CheckIcon className="size-4 shrink-0" aria-hidden="true" />
            <span>{successText}</span>
          </div>
        ) : (
          <form noValidate onSubmit={submit} data-slot="contact-section-form" className="flex flex-col gap-3">
            <Field id={`${uid}-name`} label="Name" error={shown("name")}>
              <Input type="text" autoComplete="name" placeholder="Ada Okafor" {...bind("name")} />
            </Field>
            <Field id={`${uid}-email`} label="Email" error={shown("email")}>
              <Input
                type="email"
                autoComplete="email"
                inputMode="email"
                placeholder="ada@studio.test"
                {...bind("email")}
              />
            </Field>
            <Field id={`${uid}-message`} label="Message" error={shown("message")}>
              <Textarea rows={4} placeholder="What are you building, and where are you stuck?" {...bind("message")} />
            </Field>
            <div className="flex flex-col gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between">
              <p aria-live="polite" className={cn("m-0 text-xs text-destructive", !failed && "sr-only")}>
                {failed ? "Something went wrong, try again" : ""}
              </p>
              <Button type="submit" disabled={status === "pending"} aria-busy={status === "pending" || undefined}>
                {status === "pending" && (
                  <Loader2Icon
                    data-icon="inline-start"
                    className="animate-spin motion-reduce:animate-none"
                    aria-hidden="true"
                  />
                )}
                {buttonLabel}
              </Button>
            </div>
          </form>
        )}
      </div>
    </section>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/contact-section/prompt.md, or the Copy AI prompt button above.

When to use

  • A contact or support page with three fields
  • When you want inline errors without a form library

When not to use

  • For long or multi step forms, use form-section
  • For a single email field, use email-signup

Agent notes

  • Client component; a demo that passes onSubmit must itself be "use client"
  • Errors show after a field is blurred or after the first submit, and focus moves to the first invalid field
  • The component never sends anything; wire onSubmit to your own endpoint