Tables
Table/TableRow/TableCell are the primitive; GroupedGrid adds automatic column/grouping mechanics on top. This page is the full surface — including sortable headers, sticky columns, empty/loading states, and pagination, all recent additions.
Table
A real <table> (not CSS grid), so column widths sync natively across rows. Table just owns the scroll wrapper + <table> tag — TableRow/TableCell own everything else.
| Campaign | Sent |
|---|---|
| Acme launch | 4,820 |
| Q3 follow-up | 1,240 |
| Warm re-engage | 610 |
| Holiday promo | 9,040 |
<Table>
<TableRow head>
<TableCell>Campaign</TableCell>
<TableCell align="right">Sent</TableCell>
</TableRow>
{#each campaigns as c}
<TableRow>
<TableCell>{c.name}</TableCell>
<TableCell align="right">{c.sent.toLocaleString()}</TableCell>
</TableRow>
{/each}
</Table>Cell variants
Four independent flags — combine freely (align + mono is common for a numeric id column).
| Default | Align right | Compact | Mono | Muted |
|---|---|---|---|---|
| Row label | 4,820 | Tight | evt_8f21ac | Secondary text |
<TableCell align="right">Right-aligned, tabular-nums</TableCell>
<TableCell compact>Tighter padding — dense grids</TableCell>
<TableCell mono>font-mono, for ids/emails</TableCell>
<TableCell muted>Secondary text, smaller</TableCell>Compact table
compact on every cell (not just one column) is the built-in dense-table option — 6-10px padding, tighter than most hand-rolled 'compact' tables in the app already get by copy-pasting their own padding values. Reach for this before inventing new padding.
| Campaign | Sent |
|---|---|
| Acme launch | 4,820 |
| Q3 follow-up | 1,240 |
| Warm re-engage | 610 |
| Holiday promo | 9,040 |
<!-- compact on every cell, not just one column -->
<Table>
<TableRow head>
<TableCell compact>Campaign</TableCell>
<TableCell compact align="right">Sent</TableCell>
</TableRow>
{#each campaigns as c}
<TableRow>
<TableCell compact>{c.name}</TableCell>
<TableCell compact align="right">{c.sent.toLocaleString()}</TableCell>
</TableRow>
{/each}
</Table>Row states
hoverable alone just highlights on :hover. Passing onclick makes the whole row a click target (cursor + hover both follow automatically) — hoverable is then redundant. selected applies a persistent tint, independent of hover/click.
| Campaign | Sent |
|---|---|
| Acme launch | 4,820 |
| Q3 follow-up | 1,240 |
| Warm re-engage | 610 |
| Holiday promo | 9,040 |
<TableRow hoverable>...</TableRow>
<TableRow onclick={() => openRow(id)}>...</TableRow>
<TableRow selected={row.id === selectedId}>...</TableRow>Selection
SelectAllTableCell/SelectRowTableCell are the checkbox column, pre-wrapped in a fixed-width TableCell — drop them directly inside TableRow, no manual width/padding. Use the bare SelectAllCell/SelectRowCell instead only inside a GroupedGrid column (its headerCell/cell already supplies the TableCell wrapper). SelectionBar is the matching bulk-action bar: renders nothing while count is 0, otherwise 'N selected' + your action buttons + a built-in Clear. This trio replaces what used to be five separate hand-rolled checkbox implementations across My Lists, campaigns, inboxes, and the lead/company finder tables.
| Campaign | |
|---|---|
| Acme launch | |
| Q3 follow-up | |
| Warm re-engage | |
| Holiday promo |
let selectedIds = $state(new Set());
const allSelected = $derived(selectedIds.size > 0 && selectedIds.size === campaigns.length);
const someSelected = $derived(selectedIds.size > 0 && !allSelected);
function toggleSelectAll() {
selectedIds = allSelected ? new Set() : new Set(campaigns.map((c) => c.name));
}
function toggleSelect(id) {
const s = new Set(selectedIds);
if (s.has(id)) s.delete(id);
else s.add(id);
selectedIds = s;
}
{#if selectedIds.size > 0}
<SelectionBar count={selectedIds.size} onClear={() => (selectedIds = new Set())}>
<Button variant="ghost-destructive">Delete {selectedIds.size}</Button>
</SelectionBar>
{/if}
<Table>
<TableRow head>
<SelectAllTableCell {allSelected} {someSelected} onToggleAll={toggleSelectAll} />
<TableCell>Campaign</TableCell>
</TableRow>
{#each campaigns as c}
<TableRow selected={selectedIds.has(c.name)}>
<SelectRowTableCell label={c.name} selected={selectedIds.has(c.name)} onToggle={() => toggleSelect(c.name)} />
<TableCell>{c.name}</TableCell>
</TableRow>
{/each}
</Table>Sortable headers
onSort makes a head cell clickable and renders the arrow; sortDirection (null when this isn't the active column) decides which arrow. TableCell has no sort logic of its own — the caller owns the comparator and state, same headless split as everywhere else in this design system.
| Campaign | Sent | Replies |
|---|---|---|
| Holiday promo | 9,040 | 210 |
| Acme launch | 4,820 | 62 |
| Q3 follow-up | 1,240 | 18 |
| Warm re-engage | 610 | 9 |
let sortKey = $state('sent');
let sortDir = $state('desc');
function toggleSort(key) {
if (sortKey === key) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else { sortKey = key; sortDir = 'desc'; }
}
const sorted = $derived([...campaigns].sort(/* by sortKey/sortDir */));
<TableCell onSort={() => toggleSort('sent')} sortDirection={sortKey === 'sent' ? sortDir : null}>
Sent
</TableCell>Sticky columns
sticky: true on a GridColumn pins it via position: sticky — GroupedGrid computes each sticky column's left offset for you from the (pixel) widths of the sticky columns before it. Scroll the table below sideways; Name stays put. Width must be a px value on a sticky column so the offsets can be summed.
| Name | Company | Title | Location | |
|---|---|---|---|---|
| Jane Cooper | jane@acme.com | Acme Inc | VP Sales | Austin, TX |
| Mo Ahmed | mo@globex.com | Globex | Head of Growth | Remote |
| Priya Shah | priya@initech.com | Initech | RevOps Lead | Toronto, ON |
<GroupedGrid
columns={[
{ cell: nameCell, key: 'name', label: 'Name', sticky: true, width: '140px' },
{ cell: emailCell, key: 'email', label: 'Email', width: '180px' },
{ cell: companyCell, key: 'company', label: 'Company', width: '140px' },
{ cell: titleCell, key: 'title', label: 'Title', width: '140px' },
{ cell: locationCell, key: 'location', label: 'Location', width: '140px' },
]}
{rows}
rowKey={(r) => r.email}
/>GroupedGrid — grouping
Same table mechanics as Table, plus automatic bucketing by groupBy — pass rows + groupBy, or pre-built groups for full control over sorting/merging two row sources.
| Campaign | Sent |
|---|---|
| Acme launch | 4,820 |
| Q3 follow-up | 1,240 |
| Warm re-engage | 610 |
| Holiday promo | 9,040 |
<GroupedGrid
columns={[...]}
{rows}
groupBy={(r) => r.status}
rowKey={(r) => r.name}
/>Loading state — single row
SkeletonRow is the single-row primitive — drop it in anywhere a loading state needs one row's worth of bars (a ticket/conversation loading, a single inline placeholder). For a repeated list of table rows, reach for TableSkeleton below instead — it wraps this same idea in the row loop + border/padding a real table body uses.
{#each Array.from({ length: 6 }) as _, i (i)}
<SkeletonRow avatar={false} cols={4} />
{/each}TableSkeleton
The loading-state convention for a table awaiting data: a bordered run of skeleton rows at the same padding a real row uses. checkbox adds one leading checkbox-shaped placeholder, for a table with a select column. This is what campaigns, inboxes, and email-finder's recent-finds table use instead of three divergent hand-rolled skeletons.
<TableSkeleton checkbox cols={2} rows={4} />Empty state
Drop inside a <td colspan='N'> to fill a table body, or standalone above/instead of the table. children is typically one <Button> action. icon is optional.
No campaigns yet
Create your first campaign to start sending.
No results
Try a different search or clear your filters.
<EmptyState title="No campaigns yet" description="Create your first campaign to start sending." />
<EmptyState title="No results" description="Try a different search or clear your filters.">
<Button variant="secondary" onclick={clearFilters}>Clear filters</Button>
</EmptyState>Pagination
Prev · Page X of Y · Next. Renders nothing when totalPages is 1 — safe to always mount alongside a table.
let page = $state(0);
<Pagination {page} totalPages={5} onPage={(p) => (page = p)} />