# Contact Form

> Multi-step contact form with editable CONTENT; posts to your API.

A shadcn/ui block from the [@atroui](https://www.atroui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/atroui/contact-form).

- Page, with a live preview: https://shadcn.exchange/r/atroui/contact-form
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/atroui/contact-form-light-b964827dcf0a9a33.png
- Registry item: https://www.atroui.com/r/contact-form.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.atroui.com/r/contact-form.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `registry/default/blocks/contact-form.tsx`

```tsx
"use client"

import { ArrowLeft, ArrowRight, Check, Loader2, Send } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { useState, type FormEvent } from "react"

/**
 * Edit CONTENT / PROJECT_TYPES to match your studio intake.
 * Posts JSON to CONTENT.endpoint (wire your own API route).
 * Steps reveal one at a time (Family: gradual revelation + fluid travel).
 */
const CONTENT = {
  stamp: "Contact",
  headline: "Tell us what you want to ship.",
  lede: "Four short steps. We reply within one business day.",
  endpoint: "/api/contact",
  successTitle: "Got it.",
  successBody: "We'll reply within one business day.",
  submitLabel: "Send",
}

const PROJECT_TYPES = [
  { id: "mvp-sprint", label: "7-Day MVP Sprint", hint: "from $4,800" },
  { id: "ai-integration", label: "AI Feature", hint: "from $2,400" },
  { id: "design-system", label: "Design System", hint: "from $3,600" },
  { id: "other", label: "Something else", hint: "Describe it below" },
] as const

const BUDGETS = [
  { id: "<2k", label: "Under $2k" },
  { id: "2k-5k", label: "$2k - $5k" },
  { id: "5k-10k", label: "$5k - $10k" },
  { id: "10k+", label: "$10k+" },
  { id: "unsure", label: "Not sure yet" },
] as const

const TIMELINES = [
  { id: "asap", label: "ASAP · 1-2 weeks" },
  { id: "1-month", label: "Within a month" },
  { id: "flexible", label: "Flexible" },
  { id: "unsure", label: "Not sure yet" },
] as const

const STEPS = [
  { id: "who", label: "Who", title: "Who should we reply to?" },
  { id: "what", label: "What", title: "What are we scoping?" },
  { id: "when", label: "When", title: "Timing & budget" },
  { id: "send", label: "Send", title: "Look right?" },
] as const

type Status =
  | { kind: "idle" }
  | { kind: "submitting" }
  | { kind: "success" }
  | { kind: "error"; message: string }

function Chip({
  pressed,
  onClick,
  disabled,
  children,
}: {
  pressed: boolean
  onClick: () => void
  disabled?: boolean
  children: React.ReactNode
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-pressed={pressed}
      disabled={disabled}
      className={
        pressed
          ? "border border-[var(--color-brand,#0b7bff)] bg-[var(--color-brand,#0b7bff)]/10 px-3.5 py-2.5 text-left text-sm text-foreground"
          : "border border-border-subtle bg-background px-3.5 py-2.5 text-left text-sm text-muted-foreground hover:border-border hover:text-foreground"
      }
    >
      {children}
    </button>
  )
}

export function ContactForm() {
  const reduce = useReducedMotion()
  const [step, setStep] = useState(0)
  const [status, setStatus] = useState<Status>({ kind: "idle" })
  const [form, setForm] = useState({
    name: "",
    email: "",
    company: "",
    projectType: "",
    budget: "",
    timeline: "",
    message: "",
    honeypot: "",
  })

  const canNext = () => {
    if (step === 0) return !!form.name.trim() && !!form.email.trim()
    if (step === 1) return !!form.message.trim()
    return true
  }

  const onSubmit = async (e: FormEvent) => {
    e.preventDefault()
    if (step < STEPS.length - 1) {
      if (!canNext()) return
      setStep((s) => s + 1)
      return
    }

    setStatus({ kind: "submitting" })
    try {
      const res = await fetch(CONTENT.endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      })
      if (!res.ok) {
        const data = (await res.json().catch(() => null)) as {
          error?: string
        } | null
        throw new Error(data?.error ?? "Something went wrong. Try again.")
      }
      setStatus({ kind: "success" })
    } catch (err) {
      setStatus({
        kind: "error",
        message: err instanceof Error ? err.message : "Request failed.",
      })
    }
  }

  if (status.kind === "success") {
    return (
      <motion.section
        className="border border-border-subtle p-6 sm:p-8"
        initial={{ opacity: 0, y: 8 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.28, ease: [0.32, 0.72, 0, 1] }}
      >
        <div className="flex size-10 items-center justify-center rounded-full bg-[var(--color-brand,#0b7bff)]/15 text-[var(--color-brand,#0b7bff)]">
          <Check className="size-5" aria-hidden />
        </div>
        <h2 className="mt-4 text-2xl font-medium text-foreground">
          {CONTENT.successTitle}
        </h2>
        <p className="mt-2 text-sm text-muted-foreground">{CONTENT.successBody}</p>
      </motion.section>
    )
  }

  const current = STEPS[step]!

  return (
    <section className="border border-border-subtle">
      <div className="border-b border-border-subtle px-6 py-6 sm:px-8">
        <p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
          {CONTENT.stamp}
        </p>
        <h2 className="mt-3 text-2xl font-medium tracking-tight text-foreground sm:text-3xl">
          {CONTENT.headline}
        </h2>
        <p className="mt-2 text-sm text-muted-foreground">{CONTENT.lede}</p>
        <ol className="mt-6 flex flex-wrap gap-2" aria-label="Form progress">
          {STEPS.map((s, i) => (
            <li
              key={s.id}
              className={
                i === step
                  ? "text-xs font-medium text-foreground"
                  : i < step
                    ? "text-xs text-[var(--color-brand,#0b7bff)]"
                    : "text-xs text-muted-foreground"
              }
            >
              {String(i + 1).padStart(2, "0")} {s.label}
              {i < STEPS.length - 1 ? (
                <span className="mx-2 text-border-subtle" aria-hidden>
                  /
                </span>
              ) : null}
            </li>
          ))}
        </ol>
      </div>

      <form onSubmit={onSubmit} className="space-y-6 p-6 sm:p-8">
        <input
          type="text"
          name="company_website"
          value={form.honeypot}
          onChange={(e) => setForm((f) => ({ ...f, honeypot: e.target.value }))}
          className="hidden"
          tabIndex={-1}
          autoComplete="off"
          aria-hidden
        />

        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={current.id}
            initial={reduce ? false : { opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduce ? undefined : { opacity: 0, y: -8 }}
            transition={{ duration: 0.22, ease: [0.32, 0.72, 0, 1] }}
            className="space-y-6"
            style={{ minHeight: 160 + step * 24 }}
          >
            <div>
              <h3 className="text-lg font-medium text-foreground">
                {current.title}
              </h3>
            </div>

            {step === 0 ? (
          <div className="grid gap-4 sm:grid-cols-2">
            <label className="block space-y-1.5 sm:col-span-1">
              <span className="text-xs font-medium text-muted-foreground">
                Name
              </span>
              <input
                required
                value={form.name}
                onChange={(e) =>
                  setForm((f) => ({ ...f, name: e.target.value }))
                }
                className="h-11 w-full border border-border-subtle bg-background px-3 text-sm outline-none focus:border-foreground"
              />
            </label>
            <label className="block space-y-1.5 sm:col-span-1">
              <span className="text-xs font-medium text-muted-foreground">
                Email
              </span>
              <input
                required
                type="email"
                value={form.email}
                onChange={(e) =>
                  setForm((f) => ({ ...f, email: e.target.value }))
                }
                className="h-11 w-full border border-border-subtle bg-background px-3 text-sm outline-none focus:border-foreground"
              />
            </label>
            <label className="block space-y-1.5 sm:col-span-2">
              <span className="text-xs font-medium text-muted-foreground">
                Company (optional)
              </span>
              <input
                value={form.company}
                onChange={(e) =>
                  setForm((f) => ({ ...f, company: e.target.value }))
                }
                className="h-11 w-full border border-border-subtle bg-background px-3 text-sm outline-none focus:border-foreground"
              />
            </label>
          </div>
            ) : null}

            {step === 1 ? (
          <div className="space-y-4">
            <div>
              <p className="mb-2 text-xs font-medium text-muted-foreground">
                Project type
              </p>
              <div className="grid gap-2 sm:grid-cols-2">
                {PROJECT_TYPES.map((p) => (
                  <Chip
                    key={p.id}
                    pressed={form.projectType === p.id}
                    onClick={() =>
                      setForm((f) => ({ ...f, projectType: p.id }))
                    }
                  >
                    <span className="block font-medium text-foreground">
                      {p.label}
                    </span>
                    <span className="mt-0.5 block text-xs text-muted-foreground">
                      {p.hint}
                    </span>
                  </Chip>
                ))}
              </div>
            </div>
            <label className="block space-y-1.5">
              <span className="text-xs font-medium text-muted-foreground">
                What do you want to ship?
              </span>
              <textarea
                required
                rows={4}
                value={form.message}
                onChange={(e) =>
                  setForm((f) => ({ ...f, message: e.target.value }))
                }
                className="w-full border border-border-subtle bg-background px-3 py-2.5 text-sm outline-none focus:border-foreground"
              />
            </label>
          </div>
            ) : null}

            {step === 2 ? (
          <div className="space-y-4">
            <div>
              <p className="mb-2 text-xs font-medium text-muted-foreground">
                Budget
              </p>
              <div className="flex flex-wrap gap-2">
                {BUDGETS.map((b) => (
                  <Chip
                    key={b.id}
                    pressed={form.budget === b.id}
                    onClick={() => setForm((f) => ({ ...f, budget: b.id }))}
                  >
                    {b.label}
                  </Chip>
                ))}
              </div>
            </div>
            <div>
              <p className="mb-2 text-xs font-medium text-muted-foreground">
                Timeline
              </p>
              <div className="flex flex-wrap gap-2">
                {TIMELINES.map((t) => (
                  <Chip
                    key={t.id}
                    pressed={form.timeline === t.id}
                    onClick={() => setForm((f) => ({ ...f, timeline: t.id }))}
                  >
                    {t.label}
                  </Chip>
                ))}
              </div>
            </div>
          </div>
            ) : null}

            {step === 3 ? (
          <dl className="space-y-3 text-sm">
            <div className="flex justify-between gap-4 border-b border-border-subtle pb-2">
              <dt className="text-muted-foreground">Name</dt>
              <dd className="font-medium text-foreground">{form.name}</dd>
            </div>
            <div className="flex justify-between gap-4 border-b border-border-subtle pb-2">
              <dt className="text-muted-foreground">Email</dt>
              <dd className="font-medium text-foreground">{form.email}</dd>
            </div>
            <div className="flex justify-between gap-4 border-b border-border-subtle pb-2">
              <dt className="text-muted-foreground">Project</dt>
              <dd className="font-medium text-foreground">
                {PROJECT_TYPES.find((p) => p.id === form.projectType)?.label ??
                  "Not set"}
              </dd>
            </div>
            <div className="border-b border-border-subtle pb-2">
              <dt className="text-muted-foreground">Note</dt>
              <dd className="mt-1 text-foreground">{form.message}</dd>
            </div>
          </dl>
            ) : null}
          </motion.div>
        </AnimatePresence>

        {status.kind === "error" ? (
          <p className="text-sm text-red-600 dark:text-red-400" role="alert">
            {status.message}
          </p>
        ) : null}

        <div className="flex flex-wrap items-center justify-between gap-3 pt-2">
          <button
            type="button"
            disabled={step === 0 || status.kind === "submitting"}
            onClick={() => setStep((s) => Math.max(0, s - 1))}
            className="inline-flex h-10 items-center gap-1.5 px-3 text-sm font-medium text-muted-foreground disabled:opacity-40"
          >
            <ArrowLeft className="size-3.5" aria-hidden />
            Back
          </button>
          <button
            type="submit"
            disabled={
              status.kind === "submitting" || (step < 3 && !canNext())
            }
            className="inline-flex h-11 min-w-[7.5rem] items-center justify-center gap-2 rounded-lg bg-foreground px-5 text-sm font-medium text-background disabled:opacity-50"
          >
            <AnimatePresence mode="wait" initial={false}>
              <motion.span
                key={
                  status.kind === "submitting"
                    ? "submitting"
                    : step === 3
                      ? "send"
                      : "continue"
                }
                className="inline-flex items-center gap-2"
                initial={reduce ? false : { opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? undefined : { opacity: 0, y: -4 }}
                transition={{ duration: 0.16 }}
              >
                {status.kind === "submitting" ? (
                  <>
                    <Loader2 className="size-4 animate-spin" aria-hidden />
                    Sending…
                  </>
                ) : step === 3 ? (
                  <>
                    {CONTENT.submitLabel}
                    <Send className="size-3.5" aria-hidden />
                  </>
                ) : (
                  <>
                    Continue
                    <ArrowRight className="size-3.5" aria-hidden />
                  </>
                )}
              </motion.span>
            </AnimatePresence>
          </button>
        </div>
      </form>
    </section>
  )
}
```
