Cal.comdesign-system

Forms

How Cal.com builds settings forms, dialogs, and validation with Coss UI and React Hook Form.

Forms in Cal.com product UI use React Hook Form for state and submission, Coss Field primitives for accessibility and error display, and Zod (via zodResolver) for most schema validation.

Primitive APIs for Input, Field, Dialog, etc. live on coss.com/ui. This page covers compositions we ship — not every field type. Field wiring follows the Base UI React Hook Form handbook.

Legacy @calcom/ui Form / TextField still appear in a few surfaces (notably the Cal Events editor shell). Do not add new legacy form usage — follow the Coss patterns below for new work and when migrating.

Stack

Shared Zod helpers: packages/lib/emailSchema.ts, packages/prisma/zod-utils.ts, feature schemas like packages/features/cal-events/lib/cal-event-form.ts.

Settings page card

The most common pattern for /settings — a CardFrame with fields in FieldGrid, submit in CardFrameFooter. Page chrome (SettingsShell + AppHeader) is documented in Settings page layout.

Settings card · Zod

Used for notifications and account recovery.

apps/web — settings card (pattern)

Card form conventions

  • Form className="contents" — the <form> must not break CardFrame / dialog layout (see Base UI forms handbook).
  • FieldGrid / FieldGridRow — two-column settings layout on desktop; see Field grid.
  • Submit gated on dirty — disabled={isSubmitting || !isDirty} so users don't POST unchanged data.
  • loading on submit button — tie to mutation pending state.
  • form.reset(newValues) after success — clears dirty state without a full remount.
  • Grouped fields — use Fieldset + FieldsetLegend for email lists, attribute groups, etc.

Shape reference: profile-form-card.tsx (CardFrame + dirty gating; wiring there is mixed — prefer the samples on this page). Gold field wiring: WebhookForm.tsx.

Dialog form

Create/edit flows in Dialog use the same RHF + Field wiring, with DialogPanel for fields and DialogFooter for actions.

Dialog · Zod
apps/web — SMTP configuration dialog (pattern)

Dialog form conventions

  • form.reset(defaultValues) when open changes — fresh state per open; clear stale errors.
  • onOpenChangeComplete — reset after close animation when needed.
  • PasswordField for secrets — not plain Input type="password"; see Password field. Pass onValueChange={onChange} from Controller.
  • Pre-submit gating — some dialogs block submit until a side effect succeeds (e.g. SMTP test connection). That is product logic on top of schema validation, not a separate form pattern.
  • Drawer sheets — same structure with DrawerPanel / DrawerFooter; some drawers use a native <form className="contents"> when Base UI Form would block submit.

Gold call site: smtp-configuration-dialog.tsx. Also: ApiKeyDialogForm.tsx, CreateBlocklistEntryModal.tsx, role-sheet.tsx.

Field wiring

Prefer Field + FieldLabel over a standalone Label + hand-rolled htmlFor. With Field name={…} and id={name} on the control, Base UI associates the label for you — no htmlFor on FieldLabel. Field also owns invalid / touched / dirty styling and the FieldDescription / FieldError slots. Skipping it breaks that wiring.

Every controlled input follows the same Controller → Field → control → FieldDescription? → FieldError shape:

Label extras

Standard label variants we ship. Prefer these over inventing coss particle shapes (especially anything with HTML required or standalone Label + useId).

Label extras · blocklist / availability / webhook-shaped
Optional

HTTPS endpoint that receives webhook payloads.

Optional marker — right-aligned muted label (or inline ({t("optional")}) in the FieldLabel, as in cancel-event-dialog.tsx):

Call site: CreateBlocklistEntryModal.tsx.

Helper copy — prefer FieldDescription under the control for persistent field help (see wiring sample above / WebhookForm.tsx).

InfoTooltip — short secondary help beside the label (sibling), not inside FieldLabel / Label. Prefer FieldDescription when the help should stay visible under the control. See Info tooltip.

Shape matches product call sites that put the icon next to the label (basics-tab.tsx, AttributesForm.tsx). Do not nest InfoTooltip inside FieldLabel.

Change handlers by control

For Switch, Select, and composites (InputWithAddonText, UrlInputWithAddon, DurationUnitField), keep the same Field wrapper — pass the component's value/onChange through Controller. Classify adjacent text with the Input addons and URL fields guide instead of hand-rolling an InputGroup.

Validation

When errors clear (red → valid)

With Controller + Field invalid={fieldState.invalid}, styling follows RHF, not Base UI’s native validationMode.

Keep the defaults — do not set mode / reValidateMode unless you have a reason:

Prefer clear-on-change after a failed submit. Don’t switch to blur-only clearing for settings/dialogs unless product asks for it. Use mode: "onChange" only when live validation is intentional (e.g. password strength).

Prefer coss Form (no browser bubble — but native checks still run)

@coss/ui/components/form sets noValidate so the browser balloon never appears. Base UI still runs constraint validation on submit before your onSubmit / RHF handleSubmit runs. The first field that fails HTML constraints gets focus, and RHF may never run.

  • Do validate with zodResolver or Controller rules only.
  • Do not put HTML required / minLength / pattern / type="email" (or other constraint-triggering attrs) on controls that RHF already validates — they steal focus and can block Zod/rules (e.g. empty name + invalid type="email" focuses email even when name is also wrong).
  • For email under RHF, prefer type="text" + inputMode="email" + autoComplete="email" and validate with Zod/rules.
  • Do not use a bare <form> plus HTML required plus RHF rules — same conflict without Base UI’s noValidate.
  • If you must use a native <form> (rare drawer exception), add noValidate yourself and still keep validation in RHF.

Zod schema (preferred)

Define a schema co-located with the form or in a shared package; pass zodResolver(schema) to useForm:

Use for settings cards, dialogs, auth (signup-view.tsx), and tabbed editors (cal-event-form.ts).

Controller rules (simpler forms)

When validation is a few inline checks, rules on Controller is fine — gold call site WebhookForm.tsx:

Controller rules · webhooks-shaped

HTTPS endpoint that receives webhook payloads.

apps/web — webhooks (pattern)

See WebhookForm.tsx, password-view.tsx, duplicate-cal-event-dialog.tsx.

Native-only fields (no RHF)

Rare: local state + HTML constraints, e.g. some 2FA dialogs. Then HTML required is OK only if the field is not driven by RHF, and you pair it with Base UI matchers:

Do not mix this with Controller / zodResolver on the same field.

Server and async errors

Align with Stripe / Vercel Geist: errors that need recovery stay on the form; toasts are for lightweight acknowledgments only. Do not use sticky / preserve toasts for form failures.

Where to put the form-level Alert

Primary: before the fields (top of CardPanel / DialogPanel, under the title). That is the default — findable after submit, works for long forms, matches password / signup patterns.

Long forms

  1. Top Alert summary (“Couldn’t save — try again” / “Fix the issues below”).
  2. Per-field FieldError for anything the API maps to a field.
  3. focus() the first invalid control (or the Alert if there are no field errors).
  4. Clear the form-level error on retry via submit-button onClick={() => clearErrors("apiError")} (see password-view.tsx) so the Alert dismisses immediately — including when field validation blocks onSubmit.

Live example

Submit is valid at the field level; the handler simulates a generic API failure and surfaces it with Alert above the fields (password-view / signup pattern).

Form-level Alert · generic API failure

Field is valid — submit to see a form-level Alert (not a toast).

Pattern (gold call sites)

Include apiError (or similar) on the form value type so setError is typed. Clear it when the user retries submit.

Don't: toast-only for submit failures; sticky error toasts; hand-rolled red <p> instead of Alert; put the only error message after the last field on a long page.

Other form shapes

Prefer Controller for new fields so Field gets invalid / touched / dirty. register() is acceptable for simple text lists already wired that way, but always surface errors with FieldError match={…} — not a ternary without match.

Do / don't

Do

  • Use @coss/ui/components/form + RHF handleSubmit
  • Add className="contents" when the form wraps CardFrame or DialogPopup
  • Pass full fieldState into Field
  • Put helper copy in FieldDescription (between control and FieldError)
  • Prefer Zod for anything beyond trivial required/pattern checks
  • Use onValueChange={onChange} for Input / PasswordField / Select
  • Choose addon and URL compositions from the shared decision guide
  • Gate settings submit on isDirty
  • Put form-level API failures in Alert variant="error" above the fields
  • Put user-facing strings through i18n in product code

Don't

  • Add new @calcom/ui TextField / Form usage
  • Skip Field wrapper — errors and labels won't wire correctly
  • Put HTML required / type="email" / other constraint attrs on inputs that already use RHF rules or Zod (Base UI Form validates those before RHF and steals focus)
  • Use a bare <form> without noValidate when validating with RHF
  • Replace FieldDescription with a hand-rolled muted <p>
  • Reimplement URL splitting, separators, or mobile previews inside a form
  • Use a toast (or sticky toast) as the only feedback for a failed form submit
  • Submit settings forms when nothing changed
  • Use FieldError without match when showing RHF resolver errors
  • Invent form layouts that don't exist in the app

Reference call sites