Skip to content
Corsair UI
Browse the docs

BlocksNo. 95 of 109

Contact Form

A contact section with a header, contact details and a validated message form that reports pending, success and error states.

Installation

pnpm dlx shadcn@latest add @corsair-ui/contact-form

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsbuttoncardlabelfieldinputtextarea
npm
@radix-ui/react-label@radix-ui/react-slotclass-variance-authorityclsxlucide-reacttailwind-merge

Examples

Default

The demo does not send anything: it waits a moment, then shows the success message.

Open in a new tab

Failed request

The demo does not send anything: it waits a moment, then shows the error.

Open in a new tab

API

PropTypeDefault
eyebrow

Small line above the title; null hides it.

ReactNode"Contact"
title / description

The header; the title is an h2.

ReactNode"Get in touch" / example copy
details

Under the description; null hides it.

ReactNodean email link and reply time
labels

Overrides for name, email, message, submit, pending, success and error; the rest keep their English defaults.

Partial<ContactFormLabels>—
onSubmit

Gets { name, email, message } instead of a native submit. Resolving resets the form and shows success; throwing keeps the values and shows the error.

(values: ContactFormValues) => void | Promise<void>—
formProps

Props for the form, such as action and method when there is no onSubmit.

ComponentProps<"form">—

Accessibility

  • The title is an h2. Every field has a visible label and is required, and name and email have autoComplete, so the browser checks them before onSubmit runs.
  • Success goes to a role="status" region and the error to a role="alert" region; both stay mounted so they are announced.
  • While onSubmit runs, the button is disabled, marked aria-busy and shows the pending label.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")

Source

1 file, installed as source you own
contact-form.tsxView on GitHub
"use client";

import { ClockIcon, MailIcon } from "lucide-react";
import { useId, useState, type ComponentProps, type ReactNode, type SubmitEvent } from "react";

import { cn } from "@/registry/default/lib/utils";
import { Button } from "@/registry/default/ui/button";
import { Card, CardContent } from "@/registry/default/ui/card";
import { Field, FieldLabel } from "@/registry/default/ui/field";
import { Input } from "@/registry/default/ui/input";
import { Textarea } from "@/registry/default/ui/textarea";

/** What the form hands to `onSubmit`, read from its fields with FormData. */
interface ContactFormValues {
  name: string;
  email: string;
  message: string;
}

/** Every string the form shows, including the button and status messages. */
interface ContactFormLabels {
  name: ReactNode;
  email: ReactNode;
  message: ReactNode;
  submit: ReactNode;
  /** Button text while `onSubmit` is running. */
  pending: ReactNode;
  /** Announced in the status region once `onSubmit` resolves. */
  success: ReactNode;
  /** Announced in the alert region when `onSubmit` throws. */
  error: ReactNode;
}

type ContactFormStatus = "idle" | "pending" | "success" | "error";

const defaultLabels: ContactFormLabels = {
  name: "Name",
  email: "Email",
  message: "Message",
  submit: "Send message",
  pending: "Sending…",
  success: "Thanks for your message. We'll get back to you soon.",
  error: "Something went wrong. Please try again.",
};

const defaultDetails = (
  <ul className="text-muted-foreground flex flex-col gap-3 text-sm">
    <li className="flex items-center gap-3">
      <MailIcon aria-hidden="true" className="size-4 shrink-0" />
      <a
        href="mailto:hello@example.com"
        className="text-foreground focus-visible:ring-ring/50 rounded-md underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]"
      >
        hello@example.com
      </a>
    </li>
    <li className="flex items-center gap-3">
      <ClockIcon aria-hidden="true" className="size-4 shrink-0" />
      <span>We reply within one business day</span>
    </li>
  </ul>
);

interface ContactFormProps extends Omit<ComponentProps<"section">, "title" | "onSubmit"> {
  /** Small line above the title. Pass `null` to hide it. */
  eyebrow?: ReactNode;
  /** Section title, rendered as an `<h2>`. */
  title?: ReactNode;
  description?: ReactNode;
  /** Shown under the description: contact channels, office hours. Pass `null` to hide it. */
  details?: ReactNode;
  /** Overrides for any of the form's strings; the rest keep their English defaults. */
  labels?: Partial<ContactFormLabels>;
  /**
   * Called with the field values instead of a native submit. While it runs the
   * button is disabled; resolving resets the form and shows `labels.success`,
   * throwing keeps the values and shows `labels.error`.
   */
  onSubmit?: (values: ContactFormValues) => void | Promise<void>;
  /** Props for the `<form>`, such as `action` and `method` for a native submit. */
  formProps?: ComponentProps<"form">;
}

/**
 * A contact section: a header with contact details on the left and a
 * message form in a Card on the right (stacked below `lg`). The name, email
 * and message fields are all `required` and use native validation and
 * `autoComplete`, so the browser blocks an incomplete submit before
 * `onSubmit` is called.
 *
 * With `onSubmit`, the form does not navigate: the section and form get
 * `data-status="pending"`, then `"success"` or `"error"`, which you can
 * style with `data-[status=…]:`. The success message goes to a `role="status"`
 * region and the error to a `role="alert"` region; both stay mounted (empty
 * when idle) so screen readers announce them. Without `onSubmit` the form
 * submits natively: pass `action` and `method` through `formProps`.
 *
 * @example
 * <ContactForm
 *   title="Talk to us"
 *   labels={{ submit: "Send" }}
 *   onSubmit={async (values) => {
 *     await fetch("/api/contact", { method: "POST", body: JSON.stringify(values) });
 *   }}
 * />
 */
function ContactForm({
  eyebrow = "Contact",
  title = "Get in touch",
  description = "Questions about plans, features or anything else? Send us a message and the team will reply.",
  details = defaultDetails,
  labels: labelsProp,
  onSubmit,
  formProps,
  className,
  ...props
}: ContactFormProps) {
  const id = useId();
  const [status, setStatus] = useState<ContactFormStatus>("idle");
  const labels = { ...defaultLabels, ...labelsProp };
  const pending = status === "pending";

  async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
    formProps?.onSubmit?.(event);
    // No handler, or the consumer cancelled it: leave the native submit alone.
    if (!onSubmit || event.defaultPrevented) return;
    event.preventDefault();
    if (pending) return;

    // React clears currentTarget once the event is handled, so keep the form.
    const form = event.currentTarget;
    const data = new FormData(form);
    const values: ContactFormValues = {
      name: String(data.get("name") ?? ""),
      email: String(data.get("email") ?? ""),
      message: String(data.get("message") ?? ""),
    };

    setStatus("pending");
    try {
      await onSubmit(values);
      form.reset();
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  return (
    <section
      data-slot="contact-form"
      data-status={status}
      className={cn("py-16 sm:py-24", className)}
      {...props}
    >
      <div
        data-slot="contact-form-container"
        className="mx-auto grid w-full max-w-6xl gap-12 px-4 sm:px-6 lg:grid-cols-2"
      >
        <div data-slot="contact-form-header" className="max-w-2xl">
          {eyebrow ? (
            <p
              data-slot="contact-form-eyebrow"
              className="text-muted-foreground text-sm font-medium"
            >
              {eyebrow}
            </p>
          ) : null}
          <h2
            data-slot="contact-form-title"
            className={cn(
              "text-3xl font-semibold tracking-tight text-balance sm:text-4xl",
              eyebrow ? "mt-2" : null
            )}
          >
            {title}
          </h2>
          {description ? (
            <p
              data-slot="contact-form-description"
              className="text-muted-foreground mt-4 text-lg text-pretty"
            >
              {description}
            </p>
          ) : null}
          {details ? (
            <div data-slot="contact-form-details" className="mt-8">
              {details}
            </div>
          ) : null}
        </div>
        <Card data-slot="contact-form-card" className="min-w-0">
          <CardContent>
            <form
              data-slot="contact-form-form"
              data-status={status}
              {...formProps}
              className={cn("flex flex-col gap-6", formProps?.className)}
              onSubmit={handleSubmit}
            >
              <div className="grid gap-6 sm:grid-cols-2">
                <Field>
                  <FieldLabel htmlFor={`${id}-name`}>{labels.name}</FieldLabel>
                  <Input id={`${id}-name`} name="name" autoComplete="name" required />
                </Field>
                <Field>
                  <FieldLabel htmlFor={`${id}-email`}>{labels.email}</FieldLabel>
                  <Input
                    id={`${id}-email`}
                    name="email"
                    type="email"
                    autoComplete="email"
                    required
                  />
                </Field>
              </div>
              <Field>
                <FieldLabel htmlFor={`${id}-message`}>{labels.message}</FieldLabel>
                <Textarea id={`${id}-message`} name="message" rows={5} required />
              </Field>
              <div data-slot="contact-form-footer">
                <Button
                  type="submit"
                  data-slot="contact-form-submit"
                  loading={pending}
                  className="w-full sm:w-auto"
                >
                  {pending ? labels.pending : labels.submit}
                </Button>
                <p
                  role="status"
                  data-slot="contact-form-success"
                  className="mt-4 text-sm empty:mt-0"
                >
                  {status === "success" ? labels.success : null}
                </p>
                <p
                  role="alert"
                  data-slot="contact-form-error"
                  className="text-destructive mt-4 text-sm empty:mt-0"
                >
                  {status === "error" ? labels.error : null}
                </p>
              </div>
            </form>
          </CardContent>
        </Card>
      </div>
    </section>
  );
}

export {
  ContactForm,
  type ContactFormLabels,
  type ContactFormProps,
  type ContactFormStatus,
  type ContactFormValues,
};