# Contact Form

> A contact form with validation, accessible error messages and a simulated success state.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/contact-form
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/contact-form.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/contact-form-demo.tsx"
import { ContactForm } from "@/components/contact-form"

export default function ContactFormDemo() {
  return (
    <div className="flex w-full justify-center px-4">
      <ContactForm topics={["General question", "Sales", "Technical support", "Partnership"]} />
    </div>
  )
}
```

## Source

```tsx title="components/contact-form.tsx"
"use client"

import * as React from "react"
import { CircleCheck, LoaderCircle } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ContactFormValues {
  name: string
  email: string
  topic: string
  message: string
}

export interface ContactFormProps extends Omit<React.ComponentProps<"div">, "onSubmit"> {
  topics?: string[]
  /** Called after the (simulated) submit delay */
  onSubmit?: (values: ContactFormValues) => void
}

type Errors = Partial<Record<keyof ContactFormValues, string>>

const defaultTopics = ["General question", "Sales", "Support", "Partnership"]

const fieldClass =
  "w-full rounded-lg border bg-background px-3 text-sm outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/40 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/30"

function validate(values: ContactFormValues): Errors {
  const errors: Errors = {}
  if (!values.name.trim()) errors.name = "Please enter your name."
  if (!values.email.trim()) errors.email = "Please enter your email."
  else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email.trim())) errors.email = "Enter a valid email address."
  if (!values.topic) errors.topic = "Please choose a topic."
  if (values.message.trim().length < 10) errors.message = "Tell us a bit more (at least 10 characters)."
  return errors
}

export function ContactForm({ topics = defaultTopics, onSubmit, className, ...props }: ContactFormProps) {
  const id = React.useId()
  const [values, setValues] = React.useState<ContactFormValues>({ name: "", email: "", topic: "", message: "" })
  const [errors, setErrors] = React.useState<Errors>({})
  const [status, setStatus] = React.useState<"idle" | "sending" | "sent">("idle")

  const set = (key: keyof ContactFormValues) => (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
  ) => {
    const next = e.target.value
    setValues((v) => ({ ...v, [key]: next }))
    if (errors[key]) setErrors((prev) => ({ ...prev, [key]: undefined }))
  }

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (status === "sending") return
    const found = validate(values)
    setErrors(found)
    if (Object.keys(found).length > 0) {
      const first = (Object.keys(found) as (keyof ContactFormValues)[])[0]
      const el = e.currentTarget.elements.namedItem(first)
      if (el instanceof HTMLElement) el.focus()
      return
    }
    setStatus("sending")
    const snapshot = values
    setTimeout(() => {
      setStatus("sent")
      onSubmit?.(snapshot)
    }, 800)
  }

  const reset = () => {
    setValues({ name: "", email: "", topic: "", message: "" })
    setErrors({})
    setStatus("idle")
  }

  if (status === "sent") {
    return (
      <div
        className={cn("w-full max-w-lg rounded-2xl border bg-card p-8 text-center text-card-foreground shadow-sm", className)}
        {...props}
      >
        <span className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
          <CircleCheck className="size-6" aria-hidden />
        </span>
        <h3 className="mt-4 text-lg font-semibold" role="status">
          Message sent
        </h3>
        <p className="mt-1 text-sm text-muted-foreground">
          Thanks, {values.name.trim().split(/\s+/)[0]}. We will get back to you at {values.email.trim()} within one business day.
        </p>
        <button
          type="button"
          onClick={reset}
          className="mt-6 inline-flex h-9 items-center rounded-lg border bg-background px-4 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40"
        >
          Send another message
        </button>
      </div>
    )
  }

  const describedBy = (key: keyof ContactFormValues) => (errors[key] ? `${id}-${key}-error` : undefined)
  const sending = status === "sending"

  const error = (key: keyof ContactFormValues) =>
    errors[key] ? (
      <p id={`${id}-${key}-error`} role="alert" className="text-xs text-destructive">
        {errors[key]}
      </p>
    ) : null

  return (
    <div className={cn("w-full max-w-lg rounded-2xl border bg-card p-6 text-card-foreground shadow-sm", className)} {...props}>
      <form noValidate onSubmit={handleSubmit} className="grid gap-4">
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="grid gap-1.5">
            <label htmlFor={`${id}-name`} className="text-sm font-medium">
              Name
            </label>
            <input
              id={`${id}-name`}
              name="name"
              autoComplete="name"
              value={values.name}
              onChange={set("name")}
              aria-invalid={!!errors.name}
              aria-describedby={describedBy("name")}
              disabled={sending}
              placeholder="Jane Doe"
              className={cn(fieldClass, "h-10")}
            />
            {error("name")}
          </div>
          <div className="grid gap-1.5">
            <label htmlFor={`${id}-email`} className="text-sm font-medium">
              Email
            </label>
            <input
              id={`${id}-email`}
              name="email"
              type="email"
              autoComplete="email"
              value={values.email}
              onChange={set("email")}
              aria-invalid={!!errors.email}
              aria-describedby={describedBy("email")}
              disabled={sending}
              placeholder="jane@company.com"
              className={cn(fieldClass, "h-10")}
            />
            {error("email")}
          </div>
        </div>

        <div className="grid gap-1.5">
          <label htmlFor={`${id}-topic`} className="text-sm font-medium">
            Topic
          </label>
          <select
            id={`${id}-topic`}
            name="topic"
            value={values.topic}
            onChange={set("topic")}
            aria-invalid={!!errors.topic}
            aria-describedby={describedBy("topic")}
            disabled={sending}
            className={cn(fieldClass, "h-10", !values.topic && "text-muted-foreground")}
          >
            <option value="" disabled>
              Select a topic
            </option>
            {topics.map((t) => (
              <option key={t} value={t}>
                {t}
              </option>
            ))}
          </select>
          {error("topic")}
        </div>

        <div className="grid gap-1.5">
          <label htmlFor={`${id}-message`} className="text-sm font-medium">
            Message
          </label>
          <textarea
            id={`${id}-message`}
            name="message"
            rows={5}
            value={values.message}
            onChange={set("message")}
            aria-invalid={!!errors.message}
            aria-describedby={describedBy("message")}
            disabled={sending}
            placeholder="How can we help?"
            className={cn(fieldClass, "resize-none py-2.5")}
          />
          {error("message")}
        </div>

        <button
          type="submit"
          disabled={sending}
          aria-busy={sending}
          className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-4 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-70"
        >
          {sending && <LoaderCircle className="size-4 animate-spin" aria-hidden />}
          {sending ? "Sending..." : "Send message"}
        </button>
      </form>
    </div>
  )
}
```
