Data & Charts
The flagship patterns. ReachOut is data-heavy: tables must be dense but effortless to scan, and charts must be beautiful, theme-aware and honest about what their colors mean. Everything on this page is live.
Charts
Renderers live in src/lib/charts/ and read every color from tokens — no hex anywhere. They re-render automatically on theme change and animate once on mount (disabled under reduced-motion).
Line / area chart
renderLineChart(host, { labels, series, area: true }) — trends over time. Gradient area under the first series, hover crosshair, shared tooltip.
Emails sent
Last 7 days · all campaigns
import { renderLineChart } from '../../lib/charts/line-chart';
renderLineChart(document.getElementById('chart'), {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
series: [{ name: 'Emails sent', values: [1420, 1680, 1510, 1890, 2240, 980, 860] }],
area: true,
});Grouped bar chart
renderBarChart(host, { labels, series }) — period-over-period or category comparison. Bars grow from the baseline with a light stagger.
Engagement
Weekly opens vs clicks · May 2026
renderBarChart(host, {
labels: ['W1', 'W2', 'W3', 'W4'],
series: [
{ name: 'Opens', values: [4120, 4480, 4310, 4890] },
{ name: 'Clicks', values: [620, 740, 690, 810] },
],
});Donut chart
renderDonutChart(host, { segments, centerLabel }) — share of total. Arcs sweep in on mount; hover thickens the segment.
Traffic by channel
Event volume share
renderDonutChart(host, {
segments: [
{ label: 'Email', value: 62 },
{ label: 'MCP', value: 24 },
{ label: 'API', value: 14 },
],
centerLabel: 'events',
});Heatmap
renderHeatmap(host, { columns, rows, values }) — intensity matrix using a contiguous slice of the accent ramp (unsigned-range rule).
Send volume by slot
Weekday × time block
renderHeatmap(host, {
columns: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
rows: ['09:00', '12:00', '15:00', '18:00'],
values: [[120, 140, …], …],
});Sparklines
renderSparkline(host, { values, type }) — tiny inline trends for stat cards and table cells. Line or bar, no axes.
<span class="stat-spark" data-spark='[4,7,5,9,8,12,10,14]'></span>
renderSparkline(el, { values, type: 'line' });Data tables
Built on .table-wrap + table.data-table. Numeric columns are right-aligned mono with tabular figures; text columns are Inter; status is a badge; change is a delta. One hover rule, one density system.
Full-featured table
Toolbar (title, count, search, actions), sortable headers, primary cell with sub-line, status badges, deltas, progress cell, sparkline cell, row actions, pagination.
| Status | Δ week | Trend | Delivery | |||||
|---|---|---|---|---|---|---|---|---|
Spring launch — EUcmp_01J9…A4 · segment: eu-customers | Sending | 12,408 | 41.7% | ↗ +18.2% | 64% | |||
Onboarding dripcmp_01J8…F1 · always-on | Live | 8,920 | 52.3% | ↗ +4.1% | 100% | |||
Re-engagement Q3cmp_01J7…C9 · segment: dormant-90d | Paused | 4,104 | 18.9% | ↘ −6.4% | 38% | |||
Product update — Julycmp_01J6…B2 · all contacts | Sent | 21,730 | 38.4% | → +0.2% | 100% | |||
Webinar invite — NAcmp_01J5…Z7 · segment: na-leads | Failed | 2,311 | — | ↘ −100% | 12% | |||
Weekly newsletter #42cmp_01J4…Y3 · subscribers | Scheduled | — | — | → | 0% |
<div class="table-toolbar">
<h2 class="table-toolbar-title">Campaigns</h2>
<span class="table-toolbar-count">6 rows</span>
<div class="table-toolbar-spacer"></div>
<input class="table-toolbar-search" type="search" placeholder="Filter…" />
<button class="btn btn-primary">New campaign</button>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="select-col"><input type="checkbox" /></th>
<th class="sortable" aria-sort="ascending"><button type="button" class="sort-button">Campaign <span class="sort-arrow"></span></button></th>
<th>Status</th>
<th class="num">Sent</th>
<th class="num">Open rate</th>
<th>Trend</th>
<th>Progress</th>
<th class="actions-col"></th>
</tr>
</thead>
<tbody>…</tbody>
</table>
<div class="table-pagination">…</div>
</div>Dense table
.data-table.dense — for admin views, logs and wide schemas. xs cells, tighter padding.
| Timestamp | Tool | Duration | Result |
|---|---|---|---|
| 09:32:14.201 | contacts.upsert | 84ms | ok |
| 09:32:14.488 | segments.estimate | 212ms | ok |
| 09:32:15.102 | campaigns.send | 1.4s | ok |
| 09:32:16.871 | templates.render | 46ms | timeout |
| 09:32:17.044 | templates.render | 38ms | ok |
<table class="data-table dense">…</table>Sticky header
.table-wrap.sticky with a max-height — the header stays put while rows scroll.
| Contact | Country | Opens | Clicks | Score |
|---|---|---|---|---|
| [email protected] | UK | 42 | 12 | 94 |
| [email protected] | US | 38 | 9 | 91 |
| [email protected] | UK | 35 | 11 | 89 |
| [email protected] | US | 31 | 7 | 86 |
| [email protected] | US | 29 | 8 | 84 |
| [email protected] | NL | 26 | 5 | 80 |
| [email protected] | US | 24 | 6 | 78 |
| [email protected] | US | 22 | 4 | 75 |
| [email protected] | US | 19 | 3 | 71 |
| [email protected] | US | 17 | 4 | 68 |
<div class="table-wrap sticky" style="max-height: 240px;">
<table class="data-table">…</table>
</div>Table empty state
When a filter or dataset returns nothing, the table body is replaced by the empty state inside the same frame.
No contacts match “churned”
Try a different filter, or ask your agent to broaden the segment.
<div class="table-wrap">
<div class="empty-state">
<div class="empty-state-icon">⌕</div>
<h3>No contacts match “churned”</h3>
<p>Try a different filter, or ask your agent to broaden the segment.</p>
<button class="btn">Clear filters</button>
</div>
</div>Color semantics cheat-sheet
| Data meaning | Color source | Example |
|---|---|---|
| Signed values (deltas, gains/losses) | --positive / --negative / --warning | ↗ +18.2% ↘ −3.1% |
| Unsigned ranges (intensity, heatmaps) | --accent-100…900 slice | heatmap above |
| Multi-series categories | --chart-1…8 in order | bar & donut above |
| Brand action / single trend | --accent | line chart above |
| Status (live/done/err/warn) | badge variants | Live Failed |