Modality
When Cal.com uses dialogs, drawers, sheets, alert dialogs, and popovers — and how to compose them.
Modal surfaces interrupt the current task to collect input, confirm a decision, or show focused detail. They sit above the page and block interaction with content underneath.
Use them sparingly. In Cal.com product UI, modality comes from @coss/ui — not legacy @calcom/ui dialogs.
Primitive APIs live on coss.com/ui. This page covers which component to pick and compositions we ship.
Choosing a surface
| Task | Component | When |
|---|---|---|
| Short create/edit form | Dialog | Centered, blocking, a few fields |
| Delete / disconnect / irreversible action | AlertDialog | Binary confirm; destructive primary button |
| Detail or edit from a list row | Drawer | User stays in list context; slides from the right |
| Mobile action menu / bottom peek | Drawer | showBar, often position="bottom" |
| Mobile settings nav | Drawer | position="left" |
| History, audit, admin side panel | Drawer | Read-heavy or multi-section panel |
| Inline filter / picker / hover help | Popover | Anchored to trigger; page stays interactive |
| Icon hint in dense UI | Tooltip | Single-line affordance |
Naming debt: many files are named *Sheet* but import Drawer (e.g. EditMemberSheet, BlocklistEntryDetailsSheet). Check imports, not filenames.
Short blocking form → Dialog
Irreversible confirm → AlertDialog
Edit / detail from table → Drawer (right)
History / audit panel → Drawer (@coss/ui)
Filter chip / hover help → Popover
Dialog
Centered overlays for short, focused tasks: create flows, invites, booking actions, SSO setup, previews.
<Dialog open={open} onOpenChange={onOpenChange} onOpenChangeComplete={handleOpenChangeComplete}>
<DialogPopup showCloseButton={false} className="max-w-md">
<DialogHeader>
<DialogTitle>{t("create_event_type")}</DialogTitle>
<DialogDescription>{t("create_event_type_description")}</DialogDescription>
</DialogHeader>
<Form className="contents" onSubmit={form.handleSubmit(onSubmit)}>
<DialogPanel>{/* fields */}</DialogPanel>
<DialogFooter>
<DialogClose render={<Button variant="ghost" />}>{t("cancel")}</DialogClose>
<Button type="submit" loading={isPending}>{t("continue")}</Button>
</DialogFooter>
</Form>
</DialogPopup>
</Dialog>Dialog conventions
showCloseButton={false}onDialogPopup— useDialogClose render={<Button />}in the footer (or a custom header action) instead of the default X.Form className="contents"— form participates in dialog layout without breaking header / panel / footer stacking. See Forms.onOpenChangeComplete— reset form state after the close animation, not while the dialog is still visible.className="max-w-*"onDialogPopup— width sizing (max-w-xlis common for forms).
Real call sites: CreateEventTypeDialog.tsx, MemberInvitationModal.tsx, smtp-configuration-dialog.tsx, ReassignDialog.tsx, booking-action-dialogs.tsx.
AlertDialog
For destructive or irreversible actions: delete, disconnect, cancel event, charge card, bulk remove.
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_webhook")}</AlertDialogTitle>
<AlertDialogDescription>{t("delete_webhook_confirmation_message")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="ghost" />}>{t("cancel")}</AlertDialogClose>
<Button variant="destructive" loading={isPending} onClick={onConfirm}>
{t("confirm_delete_webhook")}
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>AlertDialog conventions
- No
DialogPanel— body fields use manual padding (px-6 pb-6) when needed (cancel-event-dialog.tsx). - Primary action is destructive —
variant="destructive"for delete/remove;AlertDialogClosestays non-destructive (ghost Cancel). - Optional field in AlertDialog is still AlertDialog when the flow is fundamentally “confirm destruction” — e.g. optional cancellation reason in
cancel-event-dialog.tsx. - Prefer controlled
open/onOpenChangefrom the parent;AlertDialogTriggeris fine for inline delete buttons.
Use Dialog (not AlertDialog) for informational confirmations that are not destructive — e.g. billing period switch.
Real call sites: DeleteWebhookDialog.tsx, DeleteDialog.tsx (event types), cancel-event-dialog.tsx, disconnect-account-dialog.tsx, DeleteBulkUsers.tsx, team-danger-zone.tsx.
Drawer
Slide-over panels when the user opened something from a list or on mobile and should still feel anchored to the page.
<Drawer open={open} onOpenChange={onOpenChange} position="right">
<DrawerPopup variant="inset" className="sm:max-w-lg" showCloseButton>
<DrawerHeader>
<DrawerTitle>{t("edit_role")}</DrawerTitle>
</DrawerHeader>
<form className="contents" onSubmit={form.handleSubmit(onSubmit)}>
<DrawerPanel>{/* fields */}</DrawerPanel>
<DrawerFooter>
<DrawerClose render={<Button variant="ghost" />}>{t("cancel")}</DrawerClose>
<Button type="submit" loading={isSubmitting}>{t("save")}</Button>
</DrawerFooter>
</form>
</DrawerPopup>
</Drawer>Drawer variants
| Pattern | Props | Example |
|---|---|---|
| Edit / detail from row | position="right", variant="inset" | role-sheet.tsx, EditMemberSheet.tsx, booking-details-drawer.tsx |
| Bottom sheet | position="bottom", showBar | ActiveUserBreakdown.tsx |
| Mobile overflow menu | DrawerMenu, DrawerMenuItem, showBar | EventTypeLayout.tsx, WorkflowListPage.tsx |
| Mobile nav | position="left" | settings-drawer.tsx |
| No backdrop dim | portalProps hiding backdrop | booking-details-drawer.tsx, AvailabilityEditSheet.tsx |
Same Form className="contents" / DrawerClose render={<Button />} rules as Dialog.
Real call sites: booking-details-drawer.tsx, role-sheet.tsx, BlocklistEntryDetailsSheet.tsx, EventTypeLayout.tsx, ActiveUserBreakdown.tsx.
Sheet
@coss/ui/components/sheet is legacy. New side panels, including history and audit panels, use Drawer. Sheet is modal by default (blocks the page); existing Sheet panels move to Drawer over time.
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetPopup className="sm:max-w-2xl lg:max-w-3xl">
<SheetHeader>
<SheetTitle>{t("event_type_history")}</SheetTitle>
</SheetHeader>
<SheetPanel>{open && <EventTypeHistory eventTypeId={eventTypeId} />}</SheetPanel>
</SheetPopup>
</Sheet>Drawer vs Sheet in practice: interactive edit flows often land in Drawer even when the file says Sheet. The history panels still on Sheet (event-type-history-sheet.tsx, routing-form-history-sheet.tsx) are known debt to move to Drawer.
Real call sites: event-type-history-sheet.tsx, routing-form-history-sheet.tsx, assign-feature-sheet.tsx, agent-configuration-sheet.tsx.
Popover
Non-modal, anchored overlays — filters, date pickers, hover help. Never use for full forms or destructive confirmations.
<Popover>
<PopoverTrigger openOnHover delay={0} closeDelay={100} aria-label={tooltip}>
<InfoIcon className="size-3.5 text-muted-foreground" />
</PopoverTrigger>
<PopoverPopup side="top" tooltipStyle className="max-w-52 text-center">
<p>{tooltip}</p>
</PopoverPopup>
</Popover>Filter chips wrap Popover inside FilterChipShell — see Tables and Filter chip shell.
| Popover | Dialog |
|---|---|
| Anchored to trigger | Centered, blocks page |
| Dismisses on pick / outside click | Submit footer, validation, loading |
| Filter editors, hover tips | Create/edit workflows |
Real call sites: help-link.tsx, date-range-filter.tsx, text-filter.tsx, TravelScheduleModal.tsx (popovers inside a dialog form).
Dirty form dismissal
Modals with forms should reset on close via onOpenChangeComplete. Cal.com does not yet ship a shared “discard unsaved changes” hook across all dialogs.
What we do today:
- Reset after close animation —
onOpenChangeComplete+form.reset()(SMTP dialog, cancel-event dialog clears reason on close). - Page-level guard — Cal Events editor warns on full-page navigation with unsaved edits (
event-editor-web-wrapper.tsx). - Explicit save-before-navigate — payment app install prompts when OAuth would navigate away (
payment-app-select.tsx).
When adding dirty-close confirmation, use a nested AlertDialog (“Discard changes?” / “Keep editing”) and intercept onOpenChange — same flow described in Coss dialog docs and .agents/skills/coss-migration/references/rules/dialogs-overlays.md. Do not block route changes from inside the dialog alone.
Do / don't
Do
- Match header and primary button text to the entry point (“Delete webhook”, not “OK”).
- Use
AlertDialogfor delete/disconnect/cancel flows. - Use
Drawerfor every edge panel, including audit and history panels. - Keep
showCloseButton={false}onDialogPopupwith explicit Cancel in the footer; onDrawerPopuppassshowCloseButtonso long-scrolling panels can be dismissed from the header. - Use
Popoverfor anchored pickers;Tooltipfor single-line hints.
Don't
- Use Dialog for simple delete confirm — use AlertDialog.
- Use Popover for multi-field submit forms.
- Assume
*Sheet*in the filename means@coss/ui/components/sheet. - Reset form state in
onOpenChange(true)— useonOpenChangeComplete(false).
Reference call sites
| Pattern | Example |
|---|---|
| Dialog form (complex) | apps/web/components/dialog/ReassignDialog.tsx |
| Dialog hub | apps/web/components/booking/actions/booking-action-dialogs.tsx |
| AlertDialog delete | apps/web/modules/webhooks/components/dialogs/DeleteWebhookDialog.tsx |
| AlertDialog + optional field | apps/web/modules/events/components/cancel-event-dialog.tsx |
| Drawer detail | apps/web/modules/bookings/components/booking-details-drawer.tsx |
| Drawer edit form | apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/role-sheet.tsx |
| Drawer mobile menu | apps/web/modules/event-types/components/EventTypeLayout.tsx |
| Sheet history (legacy) | apps/web/modules/event-types/components/event-type-history-sheet.tsx |
| Sheet admin config (legacy) | apps/web/modules/ee/workflows/components/agent-configuration/agent-configuration-sheet.tsx |
| Popover filter | apps/web/modules/data-table/components/filters/date-range-filter.tsx |
| Popover hover help | apps/web/modules/ee/organizations/components/help-link.tsx |
| Migration reference | .agents/skills/coss-migration/references/rules/dialogs-overlays.md |