Skip to content
FormsContact Form
Forms

Contact FormFree

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.

Worst case

Contact Form with the data that usually breaks a component: long German words, Arabic right to left, odd numbers, empty and broken values. It runs live here, so try it. Set the frame to 320 px to see a small phone.

Loading the worst case…

Also checked with reduced motion turned on, in dark mode, and against stored 320 px screenshots with a pixel diff before every release.

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.
  • Licence: MIT. Use it in any project, commercial or not.
  • Paid access: None. No account and no key.

Installation

shadcn

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

PropTypeDefaultDescription
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.
topicsContactTopic[]Sales, Support, Press, OtherTopic chips. Defaults to Sales, Support, Press and Other. Pass [] for no chips. [] for none.
defaultTopicstringfirst topicStarting topic id.
sizesstring[]['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+']
replyWithinnumber—Business hours until a reply. With hours it skips nights and weekends. Omit for no estimate.
hoursContactHours—Business hours; adds the online/back status and makes replyWithin skip closed time.
nowDateread after mountThe 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.
messageLimitnumber2000Most 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
defaultValuesPartial<Pick<ContactData, 'name' | 'email' | 'company' | 'message'>>—Prefill. A work email also fills the company. Prefill (a signed-in visitor).
storageKeystring | null'brio:contact-form:draft'sessionStorage key for the draft; null turns drafts off. @default 'brio:contact-form:draft'
localestringruntime localeBCP 47 locale for times. Defaults to the runtime locale.
labelsPartial<ContactFormLabels>—Copy overrides, merged over the English defaults.
classNamestring—Extra class on the root (.cf-root).
styleCSSProperties—Inline styles on the root; handy for --contact-form-* variables.

ContactTopic

FieldTypeDescription
idstringSent as data.topic.
labelstringChip text.
iconReactNodeChip icon. The default topics have icons.
fields('company' | 'size')[]Extra rows for this topic.
placeholderstringMessage placeholder for this topic.
replyWithinnumberBusiness hours until a reply, for this topic (overrides replyWithin).

ContactHours

FieldTypeDefaultDescription
daysnumber[][1, 2, 3, 4, 5]Open days, 0 (Sunday) to 6. [] means closed.
startnumber9Opening and closing hour, 0–24, in timeZone. Opening hour, 0–24. @default 9
endnumber17Opening and closing hour, 0–24, in timeZone. Closing hour, 0–24, after start. @default 17
timeZonestringvisitor's zoneIANA 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 *Placeholders, 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

KeyAction
TabMove 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 chipChoose it. On the chosen team size, clears it (team size is optional).
Ctrl / ⌘ + EnterSend from the message box.
EnterSend 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

Agent prompt
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
Free set rolling out now

Pro launches in November

Contact Form is free to copy today, one of 10 free components rolling out now. The other 40 are Pro: plan comparisons, onboarding checklists, notification inboxes and more, all tested against the same worst cases. Founding members get 50% off Pro for life.

No payment. Just three emails: welcome, launch, Pro access.