Empty states
How Cal.com shows the absence of data — and what the user should do next.
Empty states convey that there is nothing to list or display on the current surface. Ideally they also give a clear next step.
In Cal.com product UI, prefer the Coss Empty primitive:
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@coss/ui/components/empty";Legacy @calcom/ui EmptyScreen still exists in a few places — migrate those call sites to Empty when you touch them. Do not introduce new EmptyScreen usage.
Anatomy
Compose parts rather than a single prop bag:
Empty— layout shell (centered column)EmptyHeader— icon + title + description groupEmptyMedia variant="icon"— contextual icon from@coss/ui/iconsEmptyTitle/EmptyDescriptionEmptyContent— optional actions (Button, secondary links)
Best practices
No data
Two situations:
- Initial state — nothing created yet
- Zero results — search or filters returned nothing
Initial state · presentational
Use when the user may be seeing the feature for the first time and needs a value prop plus a primary action. Prefer active language (“Add attributes”, “Create your first event type”) over passive “No X found”.
<Empty className="rounded-xl border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<TagsIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>{t("add_attributes")}</EmptyTitle>
<EmptyDescription>{t("add_attributes_description")}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button render={<Link href="..." />}>
<PlusIcon aria-hidden="true" />
{t("new_attribute")}
</Button>
</EmptyContent>
</Empty>Real call sites of this shape: org attributes, event types (first create), event-type webhooks, out-of-office, apps store empty.
Initial state · informational
Use when a CTA doesn’t belong here (action lives in the page header, or there’s simply nothing to do yet). Same Empty shell; omit EmptyContent.
<Empty className="rounded-xl border border-dashed" data-testid="empty-screen">
<EmptyHeader>
<EmptyMedia variant="icon">
<CalendarIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>{t("no_status_bookings_yet", { status })}</EmptyTitle>
<EmptyDescription>{t("no_status_bookings_yet_description", { ... })}</EmptyDescription>
</EmptyHeader>
</Empty>Chrome
Most full-page / list empties use a dashed border so the empty reads as a drop-in for the missing list:
<Empty className="rounded-xl border border-dashed">...</Empty>When the empty sits inside the same card used for populated data (filters stay visible above), drop the extra border on Empty and nest it in CardPanel — keeps the transition from loading → empty → data stable.
<CardFrame>
<Card>
<CardPanel className="p-0">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FileTextIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>{t("no_invoices_found")}</EmptyTitle>
<EmptyDescription>{t("no_invoices_in_date_range")}</EmptyDescription>
</EmptyHeader>
</Empty>
</CardPanel>
</Card>
</CardFrame>Zero results
After search or filters, keep the same presentation as the initial empty (same icon shell and chrome). Swap the title to search-aware copy — often t("no_result_found_for", { searchTerm }) — and keep a recovery action when one exists (create, clear search).
<EmptyTitle>
{searchTerm ? t("no_result_found_for", { searchTerm }) : t("new_event_type_heading")}
</EmptyTitle>Do not invent a separate “no results” component for list pages that already use Empty.
Loading → empty
Empty states usually appear after a skeleton. Prefer:
- Show the list skeleton (or table chrome) while pending
- Swap to
Emptyonly when the query has settled and the collection is empty
Avoid layout jump by keeping page headers / filter bars mounted in both states.
Copy
| Situation | Title tone | Example |
|---|---|---|
| First-time / create | Active | “Create your first webhook” |
| Nothing yet, no CTA | Neutral absence | “No upcoming bookings yet” |
| Search / filter miss | “No result found for …” | no_result_found_for |
| Permission / admin-only | Explain next step | “Contact your admin” / link to settings |
Always put user-facing strings through i18n (packages/i18n/locales/en/common.json or the route namespace).
Do / don’t
Do
- Import from
@coss/ui/components/empty - Pick a contextual icon (
aria-hiddenon decorative icons) - Offer a primary action in
EmptyContentwhen the user can create or recover - Reuse one Empty for both initial and zero-results; branch on
searchTerm/ filters in the title
Don’t
- Add new
@calcom/uiEmptyScreenusage - Use Empty for loading or hard errors (use skeletons /
Alert/ toasts) - Ship copy-only empties with no context (“Nothing here”) when a next step exists
- Nest a second dashed border when the parent card already provides the frame
Reference call sites
| Pattern | Example |
|---|---|
| Presentational + CTA | apps/web/modules/ee/organizations/attributes/attributes-list-view.tsx |
| Presentational + CTA | apps/web/modules/event-types/views/event-types-listing-view.tsx |
| Informational | apps/web/modules/bookings/components/bookings-list.tsx |
| In-card / filter empty | apps/web/modules/settings/billing/components/InvoicesTable.tsx |
| Dual CTA (primary + docs) | apps/web/modules/ee/organizations/privacy/blocklist-table.tsx |
| Title-only (minimal) | apps/web/modules/ee/organizations/components/OtherTeamsListing.tsx |