---
title: Contact Form
description: A contact form whose topic chips reshape it, that fills the company from a work email, tells people when they'll hear back in their own time zone, keeps drafts across reloads and folds into a summary when sent.
slug: contact-form
seoTitle: React contact form that keeps drafts across reloads
tier: free
category: Forms
dependencies: [motion]
exports: [ContactForm, ContactTopic, ContactData, ContactHours, ContactFormLabels, ContactFormProps]
---

# Contact Form

Use Contact Form for a get-in-touch page where the chosen topic decides which fields appear.

The "Get in touch" or "Contact sales" form. Topic chips (Sales, Support, Press, Other) have a pill that slides between them, and each topic reshapes the form: Sales asks for company and team size, Support doesn't, and the card's height glides to fit. A work email fills the company field from its domain. Give it your business hours and it says when a reply will actually come ("Expect a reply by tomorrow, 10:00 AM", in the visitor's time zone) and whether you're online now. A half-written message survives a reload (and a company filled from the email stays marked as such). Sending folds the form into a summary of what was sent.

## Quick answers

- **Dependencies:** `react`, `react-dom` and `motion` 12 or newer. Nothing else.
- **React version:** 19 or newer.
- **SSR and `'use client'`:** Renders on the server. The file starts with `'use client'`, so the Next.js App Router can import it straight into a Server Component. In the Pages Router, Vite, React Router and Astro that line does nothing. See [Troubleshooting](/docs/troubleshooting).
- **Licence:** MIT. Use it in any project, commercial or not.
- **Paid access:** None. No account and no key.

## Installation

### shadcn

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

### Manual

1. Copy `contact-form.tsx` into your project, for example `components/brio/contact-form.tsx`.
2. Install the peer dependency: `npm install motion`.
3. Requires React 19. The theme tokens (`--ui-*`) are optional; the component falls back to its own defaults.

## Usage

```tsx
import { ContactForm } from '@/components/brio/contact-form'

export function Contact() {
  return (
    <ContactForm
      replyWithin={8}
      hours={{ days: [1, 2, 3, 4, 5], start: 9, end: 18, timeZone: 'Europe/London' }}
      onSubmit={async data => {
        const res = await fetch('/api/contact', { method: 'POST', body: JSON.stringify(data) })
        if (!res.ok) throw new Error("Couldn't send your message. Nothing was lost.")
        return { reference: (await res.json()).id }
      }}
    />
  )
}
```

`onSubmit` gets `{ topic, name, email, company?, size?, message }`. Return `{ reference }` to show it in the summary; throw to show the error state (the error's message is shown when it has one).

## Examples

### Your own topics

Each topic can add the company and team-size rows, set its own placeholder and its own reply time.

```tsx
import { ContactForm } from '@/components/brio/contact-form'

export function Topics() {
  return (
    <ContactForm
      topics={[
        { id: 'demo', label: 'Book a demo', fields: ['company', 'size'], replyWithin: 4 },
        { id: 'billing', label: 'Billing', placeholder: 'Which invoice, and what looks wrong?' },
        { id: 'security', label: 'Report a vulnerability', replyWithin: 2 },
      ]}
      sizes={['Just me', '2–20', '21–200', '200+']}
    />
  )
}
```

### Signed-in visitors and server rendering

Prefill what you know, and pass `now` with `hours` so the server and the browser agree on the estimate.

```tsx
import { ContactForm } from '@/components/brio/contact-form'

// `user`, `hours` and `requestTime` are stand-ins for your session, opening hours and request time.
const user = { name: 'Ada Lovelace', email: 'ada@example.com' }
const hours = { days: [1, 2, 3, 4, 5], start: 9, end: 17 }
const requestTime = '2026-10-18T10:00:00Z'

export function Prefilled() {
  return <ContactForm defaultValues={{ name: user.name, email: user.email }} hours={hours} replyWithin={8} now={new Date(requestTime)} />
}
```

### Localised

```tsx
import { ContactForm } from '@/components/brio/contact-form'

export function German() {
  return (
    <ContactForm
      locale="de-DE"
      labels={{
        title: 'Kontakt',
        submit: 'Nachricht senden',
        replyBy: w => `Antwort voraussichtlich bis ${w}`,
        back: w => `Wieder erreichbar ${w}`,
      }}
    />
  )
}
```

## Props

### ContactForm

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onSubmit` | `(data: ContactData) => void \| { reference?: string } \| Promise<void \| { reference?: string }>` | — | Called with the form's data. Return (or resolve) `{ reference }` to show it; throw (or reject) to show the error state. Sends the message. |
| `topics` | `ContactTopic[]` | Sales, Support, Press, Other | Topic chips. Defaults to Sales, Support, Press and Other. Pass `[]` for no chips. `[]` for none. |
| `defaultTopic` | `string` | first topic | Starting topic id. |
| `sizes` | `string[]` | `['1–10', '11–50', '51–200', '201–1,000', '1,000+']` | Team-size chips for topics with `fields: ['size']`. @default ['1–10', '11–50', '51–200', '201–1,000', '1,000+'] |
| `replyWithin` | `number` | — | Business hours until a reply. With `hours` it skips nights and weekends. Omit for no estimate. |
| `hours` | `ContactHours` | — | Business hours; adds the online/back status and makes `replyWithin` skip closed time. |
| `now` | `Date` | read after mount | The current time. Without it the clock is read after mount, so the status and reply estimate appear on the client only; pass it to server-render them. |
| `messageLimit` | `number` | `2000` | Most characters in the message (at least 20). A counter shows in the last 20%. A longer prefill or draft is cut without splitting an emoji. Most characters in the message. @default 2000 |
| `defaultValues` | `Partial<Pick<ContactData, 'name' \| 'email' \| 'company' \| 'message'>>` | — | Prefill. A work email also fills the company. Prefill (a signed-in visitor). |
| `storageKey` | `string \| null` | `'brio:contact-form:draft'` | sessionStorage key for the draft; `null` turns drafts off. @default 'brio:contact-form:draft' |
| `locale` | `string` | runtime locale | BCP 47 locale for times. Defaults to the runtime locale. |
| `labels` | `Partial<ContactFormLabels>` | — | Copy overrides, merged over the English defaults. |
| `className` | `string` | — | Extra class on the root (`.cf-root`). |
| `style` | `CSSProperties` | — | Inline styles on the root; handy for `--contact-form-*` variables. |

### ContactTopic

| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Sent as `data.topic`. |
| `label` | `string` | Chip text. |
| `icon` | `ReactNode` | Chip icon. The default topics have icons. |
| `fields` | `('company' \| 'size')[]` | Extra rows for this topic. |
| `placeholder` | `string` | Message placeholder for this topic. |
| `replyWithin` | `number` | Business hours until a reply, for this topic (overrides `replyWithin`). |

### ContactHours

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `days` | `number[]` | `[1, 2, 3, 4, 5]` | Open days, 0 (Sunday) to 6. `[]` means closed. |
| `start` | `number` | `9` | Opening and closing hour, 0–24, in `timeZone`. Opening hour, 0–24. @default 9 |
| `end` | `number` | `17` | Opening and closing hour, 0–24, in `timeZone`. Closing hour, 0–24, after `start`. @default 17 |
| `timeZone` | `string` | visitor's zone | IANA time zone of the hours, e.g. "Europe/Berlin". Defaults to the visitor's. `'America/New_York'`. |

### ContactFormLabels

Every visible string: `title`, `topic`, `topicShort`, `name`, `email`, `company`, `size`, `message`, the four `*Placeholder`s, `fromEmail`, `left(n)`, `required`, `invalidEmail`, `tooShort`, `submit`, `sending`, `replyBy(when)`, `online`, `back(when)`, `draftRestored`, `discardDraft`, `sentTitle`, `sentBody(topic, email)`, `reference(ref)`, `sendAnother`, `failed`, `retry`. `when` reads "tomorrow, 10:00 AM" in `locale`.

## Customisation

- **Reply estimate:** counts `replyWithin` hours of open time forward from now in `hours.timeZone` (nights, weekends and closed days are skipped), then shows the result in the visitor's own time zone as "today", "tomorrow", a weekday within the week, or a date ("Tue, Oct 13") further out, joined in the locale's own style ("غدًا، ١٠:٣٠", "明日 10:00"). Without `hours`, every hour counts. Estimates more than about a year out are hidden. Overnight hours (an `end` before `start`) and an invalid `now` show no status or estimate and warn once in development.
- **Company autofill:** works for company domains, including second-level ones such as `.co.uk`. Free and personal mail (Gmail, Outlook, iCloud, Proton and others) and punycode domains (`xn--…`) never fill it. Typing in the field stops the autofill.
- **Validation:** name and email are required, the email must look like one, and the message needs at least 10 characters. Errors appear in the label row once a field you typed in is left, or after a send attempt (tabbing past an empty field never flags it). The label row keeps one fixed height, so nothing below moves.
- **Theme tokens:** `--ui-ink`, `--ui-surface`, `--ui-accent` (focus), `--ui-ok` (online dot, autofill tint, check), `--ui-danger`, `--ui-warn`. Corners: `--ui-radius` (the card), `--ui-radius-sm` (fields and buttons, scaled from it), `--ui-radius-pill` (chips).
- **Component CSS variables:** `--contact-form-ink`, `--contact-form-surface`, `--contact-form-accent`, `--contact-form-ok`, `--contact-form-danger`, `--contact-form-radius`, `--contact-form-max-width` (default `72ch`; set `none` to let the card fill its container).
- **Class names:** `className` goes on `.cf-root` (`[data-phase="form" | "sending" | "sent" | "error"]`). Stable hooks: `.cf-status[data-open]`, `.cf-chips`, `.cf-chip[aria-checked]`, `.cf-field[data-invalid]`, `.cf-input`, `.cf-message`, `.cf-submit`, `.cf-reply`, `.cf-draft`, `.cf-fail`, `.cf-sent`, `.cf-summary`.

## Responsive behaviour

- Name and email sit side by side from 460px of card width (container queries) and stack below it.
- Chips wrap onto more lines rather than shrinking; labels wrap instead of truncating. A long list of topics or sizes scrolls inside about seven rows (never more than half the screen, so short landscape phones keep the form in view), with a fade on the clipped edge; arrow keys scroll the chosen chip into view. With a mouse the list keeps the wheel; on touch a swipe that reaches the end of the list carries on to the page.
- Long unbroken text (emails, URLs, references, error messages) breaks anywhere instead of leaving the card; a long send label wraps inside the button.
- The summary's rows stack under 380px. Rows are separated by space, not dividers.
- Wide containers: the card stops at `72ch` (`--contact-form-max-width`), so the message box and the summary keep a readable line length.
- On coarse pointers topic and size chips and "Send another" grow to 44px, "Discard" gets a 44px hit area, and fields use 16px text (no zoom on iOS). Fine pointers look unchanged.
- Tabbing onto the message box when it sits partly off screen scrolls the whole box into view.

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Move through the topic group, fields, size group and the button. |
| `←` `→` / `↑` `↓` | Choose a topic or size inside its group (mirrored in RTL). |
| `Space` / `Enter` on a chip | Choose it. On the chosen team size, clears it (team size is optional). |
| `Ctrl` / `⌘` + `Enter` | Send from the message box. |
| `Enter` | Send from any single-line field or the send button. |

Focus is never dropped: it stays on the send button (or the field you sent from) while sending and through a failed send, moves to "Message sent" when the form folds into the summary (the next `Tab` reaches "Send another"), and goes to the message box after "Send another" or "Discard".

## Accessibility

- A real `<form>` with `<label>`s for every field; errors use `aria-invalid` and `aria-describedby`, the first bad field takes focus and the bad ones shake once.
- Topic and team-size chips are `role="radiogroup"` with `role="radio"` buttons and roving focus.
- Sending, sent and the restored-draft note are announced (`aria-live` / `role="status"`); a failed send is a `role="alert"`.
- The send button keeps its width across "Send message", "Sending…" and "Try again" (all three share one cell). While sending it is `aria-disabled` and `aria-busy` rather than `disabled`, so it keeps focus; fields turn read-only instead of disabled for the same reason.
- The sent summary is a labelled group that takes focus when it appears.
- Muted text (status, hints, summary labels) and the failure note meet 4.5:1 in both themes.
- Reply times are wrapped in bidi isolates, so they read correctly inside RTL labels; user text in the summary and chips uses `dir="auto"`.
- Reduced motion: fades instead of slides, no shake, the check appears drawn.

## Agent prompt

```text
Add the Brio UI "Contact Form" component to this project.
1. Run `npx shadcn@latest add https://brioui.com/r/contact-form.json`.
2. Make sure `motion` is installed and React is 19 or newer.
3. Import it from the file the CLI created and render it with `onSubmit` posting the data to the project's contact endpoint (throw on failure; return `{ reference }` if the backend gives an id).
4. Ask the user for their business hours and usual reply time, then pass `hours` ({ days, start, end, timeZone }) and `replyWithin`; pass `now` if the page is server rendered.
Full docs: https://brioui.com/docs/components/contact-form.md
```
