Component & design-token showcase

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
Code
<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
Code
<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
Code
<!-- 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
Code
<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
Code
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
Code
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.

NameEmailCompanyTitleLocation
Jane Cooperjane@acme.comAcme IncVP SalesAustin, TX
Mo Ahmedmo@globex.comGlobexHead of GrowthRemote
Priya Shahpriya@initech.comInitechRevOps LeadToronto, ON
Code
<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.

CampaignSent
Acme launch4,820
Q3 follow-up1,240
Warm re-engage610
Holiday promo9,040
Code
<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.

Code
{#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.

Code
<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.

Code
<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.

Page 1 of 5
Code
let page = $state(0);

<Pagination {page} totalPages={5} onPage={(p) => (page = p)} />