Tables
How Cal.com lists, filters, sorts, and paginates tabular data with Coss UI and TanStack Table.
List and settings pages in Cal.com use a Cal-specific data-table module on top of TanStack Table and Coss table primitives — not legacy @calcom/ui DataTable.
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@coss/ui/components/table";
import { CardFrame, CardFrameFooter } from "@coss/ui/components/card";
import { flexRender, useTable } from "@tanstack/react-table";
import { dataTableFeatures } from "~/data-table/hooks/use-enhanced-table";Infrastructure lives in apps/web/modules/data-table/. Filter types and serializers live in packages/features/data-table/.
Primitive Table APIs are on coss.com/ui. This page covers product compositions.
Stack
| Layer | Location | Role |
|---|---|---|
| TanStack Table | @tanstack/react-table | Columns, sorting, filtering, pagination, selection, visibility |
| Data-table module | apps/web/modules/data-table/ | Provider, toolbar, wrapper, pagination, filter chips |
| Coss table | @coss/ui/components/table | Table variant="card" inside CardFrame + ScrollArea |
| Cal composites | @coss/ui/shared/* | Column visibility, filter add menu, filter chips, sortable header (bespoke tables) |
Typical list page:
DataTableProvider
→ DataTableToolbar (search, column visibility, filter add, active chips)
→ DataTableWrapper (empty / error / loading / table)
→ DataTable (CardFrame + Table variant="card")
→ DataTablePagination (CardFrameFooter)
→ DataTableSelectionBar (optional bulk actions)
Legacy @calcom/ui DataTable is not used in current apps/web code. Do not introduce it.
List page (most common)
Admin and settings lists combine a toolbar above a card table. Toolbar layout matches DataTableToolbar:
- Search on the left (
SearchField,sm, max width ~200px) - Column visibility + Filter add on the right
- Active filter chips on a second row (
FilterChipShellchips +FilterBarActionsfor Clear / Save segment)
Name | Email | Role |
|---|---|---|
| Alex Rivera | alex@acme.com | Admin |
| Jordan Lee | jordan@acme.com | Member |
| Sam Patel | sam@acme.com | Member |
| Riley Chen | riley@acme.com | Member |
| Casey Morgan | casey@acme.com | Member |
<div className="grid w-full items-center gap-2 pb-4">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<SearchField className="w-full sm:max-w-[200px]" size="sm" />
<div className="flex w-full items-center gap-2 sm:flex-1 sm:justify-end">
<ColumnVisibilityButton buttonSize="sm" table={table} />
<FilterAddMenu buttonSize="sm" table={table} />
</div>
</div>
<ActiveFilters table={table} />
</div>Related atom docs: Column visibility menu, Filter add menu, Filter chip shell, Filter bar actions.
Real call sites: UserListTable.tsx, MemberList.tsx, AbuseRulesView.tsx, BlockedEntriesTable.tsx.
Card table shell
Interactive tables render inside CardFrame with Table variant="card" and horizontal scroll via ScrollArea. Sortable columns use clickable header cells with chevron direction — built into DataTable, not a separate component.
<CardFrame ref={tableContainerRef} className="w-full">
<Table render={<ScrollArea clampContentMinWidth={false} />} variant="card">
<TableHeader>{/* sortable TableHead cells */}</TableHeader>
<TableBody>{/* flexRender rows */}</TableBody>
</Table>
{footer ? <CardFrameFooter className="border-t px-3 py-2">{footer}</CardFrameFooter> : null}
</CardFrame>Card backdrop overrides are elided above: data-table.tsx also passes a dimmed --border on Table and a matching before:bg on CardFrame. Without them the card-variant table draws its rounded outline at full border weight and the footer detaches into a separate block below — copy them verbatim from data-table.tsx.
Pagination sits in CardFrameFooter — page size Select on the left, range label + prev/next on the right (DataTablePagination). Cursor pagination uses the same footer with a page counter instead of total count.
Row actions live in column defs (menu button in an actions column). Row click opens a drawer with details when onRowMouseclick is set — ignore clicks on buttons/links inside the row.
Column resize and pinned columns are supported in DataTable for wide admin tables (members, blocklist).
Empty states
Two patterns — see also Empty states.
In-card empty (filters stay mounted)
When filters or date range sit above the table, nest Empty in CardPanel without an extra dashed border. Keeps loading → empty → data transitions stable.
Used in InvoicesTable.tsx, filterable list pages via DataTableWrapper + EmptyView.
Full-page empty with CTA
When the whole list is empty (no rows at all), pass a presentational Empty with EmptyContent as EmptyView to DataTableWrapper — same as blocklist / attributes list empties.
DataTable alone falls back to a centered “No results” cell when the wrapper does not supply EmptyView and TanStack returns zero rows after filters.
Loading
Prefer row skeletons while refetching so headers, toolbar, and footer stay mounted:
<DataTableWrapper
loading={{
mode: "rows",
rowCount: 10,
renderRow: (rowIndex) => <BlockedEntriesSkeletonRow rowIndex={rowIndex} table={table} />,
}}
isPending={isPending}
...
/>Match skeleton shape per column (Skeleton widths per columnId) — not one generic bar per row.
For first paint with no chrome yet, loading.mode: "table" swaps in a full DataTableSkeleton.
| Name | Role | |
|---|---|---|
Simple static table
Small, non-interactive lists can use plain Table (default variant) without TanStack or CardFrame — no sort, filter, or pagination chrome.
| Provider | Status | Timestamp | Actions |
|---|---|---|---|
| Google Calendar | Success | 2 minutes ago | |
| Outlook | Failed | 1 hour ago |
Real call site: integration-sync-runs-table.tsx.
Bespoke tables
Most product tables go through DataTable + DataTableWrapper. blocklist-table.tsx builds TanStack + Coss table directly and uses TableSortableHeaderButton + ColumnVisibilityMenu in the header row — reference when you cannot use the shared module (tabs, custom layout).
Bookings is another exception: TanStack state for filters/sort/pagination, but rows render as ListItem inside CardFrame instead of <table> rows.
Do / don't
Do
- Use
apps/web/modules/data-table/for new filterable list pages - Wrap interactive tables in
CardFrame+Table variant="card" - Keep toolbar + filter bar mounted across loading / empty / data states
- Use
@coss/ui/components/emptyfor meaningful empties (with CTA when the user can create) - Use per-column skeleton cells during row loading
Don't
- Add legacy
@calcom/uiDataTable - Drop filter chrome when showing an in-card empty
- Use
TableSortableHeaderButtonin standard list pages —DataTableheader sort is the default - Show a generic “No results” cell when a full Empty with next steps is appropriate
Reference call sites
| Pattern | Example |
|---|---|
| Full list page (gold standard) | apps/web/modules/users/components/UserTable/UserListTable.tsx |
| Toolbar + wrapper module | apps/web/modules/data-table/components/data-table-toolbar.tsx |
| Card table rendering | apps/web/modules/data-table/components/data-table.tsx |
| Empty / loading orchestration | apps/web/modules/data-table/components/data-table-wrapper.tsx |
| Pagination footer | apps/web/modules/data-table/components/data-table-pagination.tsx |
| Active filter chips | apps/web/modules/data-table/components/active-filters.tsx |
| In-card empty + date filter | apps/web/modules/settings/billing/components/InvoicesTable.tsx |
| Row skeletons | apps/web/modules/blocklist/components/BlockedEntriesTable.tsx |
| Bespoke table + sortable header | apps/web/modules/ee/organizations/privacy/blocklist-table.tsx |
Card list (not <table>) | apps/web/modules/bookings/components/bookings-list.tsx |
| Simple static table | apps/web/modules/integration-sync-audit/components/integration-sync-runs-table.tsx |
| Custom toolbar layout | apps/web/modules/insights/views/insights-call-history-view.tsx |