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.
import { Form } from "@coss/ui/components/form";
import { Field, FieldDescription, FieldError, FieldLabel } from "@coss/ui/components/field";
import { Input } from "@coss/ui/components/input";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";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
| Layer | Choice | Notes |
|---|---|---|
| State | react-hook-form | useForm, Controller, FormProvider, useFieldArray |
| Validation | Zod + zodResolver | Preferred for settings cards and dialogs |
| Validation (simple) | Controller rules | Webhooks, some password flows — no Zod schema |
| Form element | @coss/ui/components/form | Base UI wrapper; sets noValidate; use className="contents" inside cards/dialogs |
| Field chrome | Field + FieldLabel + FieldDescription + FieldError | Wire invalid, touched, dirty from fieldState |
| Field-mapped API errors | form.setError("slug", …) | Surfaces via FieldError |
| Form-level API / network errors | Alert variant="error" before fields | Not a toast — see Server and async errors |
| Success acknowledgments | toastManager.add() | Lightweight, auto-dismiss; optional when the UI already shows Saved |
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.
const formMethods = useForm<ProfileFormValues>({
defaultValues,
resolver: zodResolver(profileFormSchema),
});
const { isSubmitting, isDirty } = formMethods.formState;
const isDisabled = isSubmitting || !isDirty;
return (
<Form className="contents" onSubmit={formMethods.handleSubmit(handleFormSubmit)}>
<CardFrame>
<Card>
<CardPanel>
<FieldGrid>
<FieldGridRow>
<Controller
name="name"
control={formMethods.control}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<FieldLabel>{t("full_name")}</FieldLabel>
<Input
id={name}
ref={ref}
name={name}
value={value}
onBlur={onBlur}
onValueChange={onChange}
/>
<FieldError match={!!error}>{error?.message}</FieldError>
</Field>
)}
/>
</FieldGridRow>
</FieldGrid>
</CardPanel>
</Card>
<CardFrameFooter className="flex justify-end">
<Button disabled={isDisabled} loading={isPending} type="submit">
{t("update")}
</Button>
</CardFrameFooter>
</CardFrame>
</Form>
);Card form conventions
Form className="contents"— the<form>must not breakCardFrame/ 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. loadingon submit button — tie to mutation pending state.form.reset(newValues)after success — clears dirty state without a full remount.- Grouped fields — use
Fieldset+FieldsetLegendfor 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 open={open} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
<DialogPopup showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t("smtp_setup")}</DialogTitle>
</DialogHeader>
<Form className="contents" onSubmit={form.handleSubmit(onSubmit)}>
<DialogPanel>
<div className="flex flex-col gap-4">{/* Controller + Field per input */}</div>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button variant="ghost" />}>{t("cancel")}</DialogClose>
<Button loading={isSubmitting} type="submit">
{submitLabel}
</Button>
</DialogFooter>
</Form>
</DialogPopup>
</Dialog>Dialog form conventions
form.reset(defaultValues)whenopenchanges — fresh state per open; clear stale errors.onOpenChangeComplete— reset after close animation when needed.PasswordFieldfor secrets — not plainInput type="password"; see Password field. PassonValueChange={onChange}fromController.- 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:
<Controller
name="subscriberUrl"
control={form.control}
rules={{
required: t("error_required_field"),
validate: (value) => {
if (!value) return true;
try {
new URL(value);
return true;
} catch {
return t("invalid_url");
}
},
}}
render={({
field: { ref, name, value, onBlur, onChange },
fieldState: { invalid, isTouched, isDirty, error },
}) => (
<Field name={name} invalid={invalid} touched={isTouched} dirty={isDirty}>
<FieldLabel>{t("subscriber_url")}</FieldLabel>
<Input
id={name}
ref={ref}
name={name}
value={value}
onBlur={onBlur}
onValueChange={onChange}
placeholder="https://example.com/webhook"
/>
<FieldDescription>{t("subscriber_url_description")}</FieldDescription>
<FieldError match={!!error}>{error?.message}</FieldError>
</Field>
)}
/>| Prop / piece | Source | Purpose |
|---|---|---|
Field name={name} | RHF field.name | Accessibility + Base UI validation wiring |
invalid | fieldState.invalid | data-invalid styling |
touched / dirty | fieldState | Optional UX (show errors after touch) |
id={name} on input | Same as field name | Lets Field associate FieldLabel — no htmlFor needed |
FieldDescription | Static helper copy | Between control and error — not a raw <p> |
FieldError match={!!error} | RHF or Zod message | Show resolver / rules errors |
Label extras
Standard label variants we ship. Prefer these over inventing coss particle shapes (especially anything with HTML required or standalone Label + useId).
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):
<div className="inline-flex w-full items-center justify-between gap-2">
<FieldLabel>{t("description")}</FieldLabel>
<Label className="font-normal text-muted-foreground" render={<span />}>
{t("optional")}
</Label>
</div>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.
<div className="flex items-center gap-1.5">
<FieldLabel>{t("minimum_buffer_before_events")}</FieldLabel>
<InfoTooltip ariaLabel={t("minimum_buffer_before_events")} content={t("…")} />
</div>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
| Control | RHF wiring |
|---|---|
Input, PasswordField, Select, NumberField, DurationUnitField | onValueChange={onChange} |
Switch | onCheckedChange={onChange} (or checked + onCheckedChange) |
Textarea | onChange={(e) => onChange(e.target.value)} — no onValueChange API |
| Combobox / complex composites | Map their value callbacks into onChange (see WebhookForm.tsx) |
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:
| Default | Effect | |
|---|---|---|
RHF mode | onSubmit | First errors appear on submit |
RHF reValidateMode | onChange | After that, the field rechecks as the user types and drops red/FieldError when valid |
Base UI validationMode (native path only) | onSubmit | Same idea: submit first, then revalidate on change |
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
zodResolverorControllerrulesonly. - 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 + invalidtype="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 HTMLrequiredplus RHFrules— same conflict without Base UI’snoValidate. - If you must use a native
<form>(rare drawer exception), addnoValidateyourself 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:
const profileFormSchema = z.object({
name: z.string().trim().min(1, t("you_need_to_add_a_name")),
email: emailSchema.toLowerCase(),
});
useForm({ resolver: zodResolver(profileFormSchema), defaultValues });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
name="subscriberUrl"
control={control}
rules={{
required: t("error_required_field"),
validate: (value) => {
/* custom URL check → string message or true */
},
}}
render={/* Field + Input onValueChange + FieldError match={!!error} */}
/>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:
<Field name="password" invalid={!!errorMessage}>
<FieldLabel htmlFor="password">{t("password")}</FieldLabel>
<PasswordField id="password" name="password" required value={password} onChange={…} />
<FieldError match={errorMessage ? true : "valueMissing"}>
{errorMessage ?? t("error_required_field")}
</FieldError>
</Field>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.
| Situation | Prefer | Notes |
|---|---|---|
| Error maps to one field (slug taken, bad password) | form.setError("field", { message }) → FieldError | Keep the message next to the fix |
| Whole-form / network / 5xx / unknown on this submit | Alert variant="error" above the fields | Persistent until clear / retry; role="alert" |
Soft success (Saved, dialog closed) | Toast or inline “Saved” near the button | Toast OK; skip if the UI already shows success |
| Background / out-of-form system events | Toast or shell banner | Not for the open form’s submit |
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.
| Surface | Placement |
|---|---|
Settings CardFrame | Top of CardPanel, above FieldGrid |
| Dialog / drawer | Top of DialogPanel / DrawerPanel, above fields |
| Long / tabbed editor | Top of the active panel + field FieldErrors; focus first invalid field |
| Sticky footer only | Optional compact alert above the footer in addition to the top alert — never footer-only on long pages |
Long forms
- Top Alert summary (“Couldn’t save — try again” / “Fix the issues below”).
- Per-field
FieldErrorfor anything the API maps to a field. focus()the first invalid control (or the Alert if there are no field errors).- Clear the form-level error on retry via submit-button
onClick={() => clearErrors("apiError")}(seepassword-view.tsx) so the Alert dismisses immediately — including when field validation blocksonSubmit.
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).
Pattern (gold call sites)
// mutation onError — password-view.tsx, signup-view.tsx
form.setError("apiError", { message: t(error.message), type: "custom" });
// render — before fields, not after
{form.formState.errors.apiError ? (
<Alert variant="error">
<CircleAlertIcon />
<AlertTitle>{t("error_updating_password")}</AlertTitle>
<AlertDescription>{form.formState.errors.apiError.message}</AlertDescription>
</Alert>
) : null}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
| Shape | When | Example |
|---|---|---|
| Tabbed page editor | One form, many tabs via FormProvider + useFormContext | Cal Events editor |
| FormProvider wrapper | Child components register fields without prop drilling | WebhookForm.tsx, signup-view.tsx |
| Extracted field components | Reuse fields across create/edit modals | OAuthClientFormFields.tsx |
| Inline edit | Single value in a heading | InlineEditableText in availability settings |
| Toggle cards | Boolean setting + optional nested dialog | Shared SettingsToggle, event-type SettingsToggle |
| Addon / URL input | Unit, currency, fixed suffix, or variable URL prefix | Input addons and URL fields |
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+ RHFhandleSubmit - Add
className="contents"when the form wrapsCardFrameorDialogPopup - Pass full
fieldStateintoField - Put helper copy in
FieldDescription(between control andFieldError) - Prefer Zod for anything beyond trivial required/pattern checks
- Use
onValueChange={onChange}forInput/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/uiTextField/Formusage - Skip
Fieldwrapper — errors and labels won't wire correctly - Put HTML
required/type="email"/ other constraint attrs on inputs that already use RHFrulesor Zod (Base UI Form validates those before RHF and steals focus) - Use a bare
<form>withoutnoValidatewhen validating with RHF - Replace
FieldDescriptionwith 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
FieldErrorwithoutmatchwhen showing RHF resolver errors - Invent form layouts that don't exist in the app
Reference call sites
| Pattern | Example | Notes |
|---|---|---|
Field wiring gold (rules, FieldDescription, onValueChange) | apps/web/modules/webhooks/components/WebhookForm.tsx | Copy this for Controller composition |
| Right “Optional” label | apps/web/modules/blocklist/components/CreateBlocklistEntryModal.tsx | Or inline (optional) in cancel-event-dialog.tsx |
InfoTooltip beside label | apps/web/modules/events/components/tabs/basics-tab.tsx | Sibling of the label — never nested in FieldLabel |
| Zod dialog gold | apps/web/modules/ee/organizations/smtp-configuration/smtp-configuration-dialog.tsx | Reset on open, full FieldError coverage |
| Settings card shape | apps/web/modules/settings/my-account/components/profile-form-card.tsx | CardFrame + dirty gate; prefer this page's Field samples for wiring |
| Settings card + slug addon | apps/web/modules/ee/organizations/components/org-profile-form-card.tsx | Shape reference; ensure FieldError / FieldDescription when editing |
| Dialog + custom resolver | apps/web/modules/ee/api-keys/components/ApiKeyDialogForm.tsx | Uses FieldDescription |
Form-level Alert + apiError | apps/web/modules/settings/security/password-view.tsx | Alert above fields; clear on retry |
| Auth / signup form Alert | apps/web/modules/signup-view.tsx | errors.apiError → Alert before fields |
| Tabbed editor + shared schema | apps/web/modules/events/components/event-editor-web-wrapper.tsx | |
| Drawer form | apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/role-sheet.tsx | |
| Migration reference | .agents/skills/coss-migration/references/rules/forms-inputs.md | Keep in sync with this guide |
On This Page
Form (no browser bubble — but native checks still run)Zod schema (preferred)Controller rules (simpler forms)Native-only fields (no RHF)Server and async errorsWhere to put the form-level AlertLong formsLive examplePattern (gold call sites)Other form shapesDo / don'tReference call sites