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
| Prop | Type | Default | Description |
|---|---|---|---|
| eyebrow | string | Small uppercase line above the title. | |
| title | ReactNode | The section heading. | |
| description | ReactNode | One 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. | |
| buttonLabel | string | "Send message" | Label of the submit button. |
| successText | string | Shown once the submit settles. | |
| minMessage | number | 20 | Minimum 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