Analytics Display
Composing KPI tiles, trends, ranked breakdowns, and cross-filtering into analytics dashboards with the Chart Kit.
Overview
Analytics surfaces answer "how is it going?" — and they should answer it the same way everywhere: the same tile shape for headline numbers, the same line language for trends, the same bars for rankings, the same empty and loading states.
The Chart Kit (@fsai/shared-ui) supplies the parts in three tiers. This page defines how they compose into a full analytics view: the band order, the tile shape, where switchers live, how cross-filtering behaves, and which states every surface must handle.
See the Charts component page for the full per-component API. This page is about composition.
Choosing the Right Tier
The tier answers "how much chart does this number deserve?" — pick by where the reading happens, not by how important the metric is.
Tier 1 — Micro marks (inside a row or tile)
No axes, no legend, no card, no title of their own. They borrow context from the row or tile they live in.
Delta— change vs the previous period. Renders "New" when there is no prior period (value: null). Direction is colored by intent viaupIsGood, not by sign — a falling cost is good.Sparkline— the shape of a series, no scale. Returnsnullwhen there is too little signal (fewer than 2 points, or fewer than 3 non-zero values) — treat "no sparkline" as a valid outcome and don't reserve space for one.MagnitudeBar— one value against a known ceiling, inside a table cell or list row. Reads as a proportion, so it carries no axis.HBarRow/HBarChart— a ranked comparison where the label leads and the bar compares. Beats a pie for more than five categories.HBarChartowns the shared ceiling so callers stop recomputingMath.max(...)per list.
Tier 2 — The plot system (one axis language)
LineChartandBarChartrender a plot and nothing else.TrendChartisLineChartwith a comparison series — current vs previous period.ChartCardsupplies the frame: title badge, header slots, border. Keeping frame and plot separate is what lets a page put a metric switcher in the header without the chart knowing a switcher exists.
Tier 3 — Specialized forms
Heatmap— when, across a week, activity lands (7×24 grid). Needs about two full cycles of data to be worth drawing.PieChart— composition of a small set (≤ 5 slices). Self-framing: it takes atitleand renders its own card with a list view.GeoChart— US or Canada choropleth. Also self-framing withtitle,valueLabel, and a list view.
Do not wrap PieChart or GeoChart in a ChartCard — they carry their own frame. ChartCard is for Tier 2 plots.
Page Anatomy
Analytics pages follow a canonical top-to-bottom order. Not every dashboard needs every band, but keep the relative order of the bands you use:
- Filter row — date range, scope/segment dropdowns, "Updated X ago", refresh.
- Active-filter bar — sticky, only rendered when cross-filters are active. Removable
Badgechips + "Reset All Filters". - KPI row — a responsive grid of
StatTiles. The headline numbers. - Insights strip — optional short prose takeaways ("This period at a glance").
- Primary trend — the one big time-series section with a metric switcher and period comparison.
- Breakdown sections — channel/category performance, leaderboards, ranked lists, in two-up grids.
- Specialized sections — timing heatmap, format performance, audience snapshot.
<div className="flex flex-col gap-5">
{filterRow}
{activeFilterBar /* sticky, conditional */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
{kpiTiles}
</div>
{primaryTrendSection}
<div className="grid gap-5 xl:grid-cols-5">
{channelSection /* xl:col-span-2 */}
{leaderboardSection /* xl:col-span-3 */}
</div>
<div className="grid gap-5 xl:grid-cols-2">
{heatmapSection}
{formatSection}
</div>
</div>KPI Row: StatTile + Delta + Sparkline
The stat tile is the platform's headline-number shape: label on top, big value, change context below, optional sparkline on the right.
import { Delta, Sparkline, StatTile } from '@fsai/shared-ui';
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
{kpis.map((kpi) => (
<StatTile
key={kpi.label}
label={kpi.label}
value={kpi.value}
context={<Delta value={kpi.delta} suffix={kpi.deltaSuffix ?? '%'} />}
spark={kpi.spark ? <Sparkline points={kpi.spark} /> : undefined}
/>
))}
</div>Conventions:
valueis a pre-formatted string — usefmtCompactstyle formatting (12.4K), not raw numbers.Deltahandles the "no previous period" case itself (value={null}renders "New"); don't invent a custom placeholder.- For metrics where down is good (cost, churn, unsubscribes), pass
upIsGood={false}— never restyle the colors manually. StatTileacceptsonClick+activeand becomes a filter toggle (see Cross-Filtering below).
Primary Trend: Metric Switcher + Period Comparison
The main time-series section combines four parts: a section header with the controls, a caller-supplied legend, and TrendChart. The section frame is a bordered surface card with a header row — title and subtitle on the left, controls on the right:
import {
chartAccentColor,
chartMutedColor,
SegmentedControl,
SeriesLegend,
Toggle,
TrendChart,
} from '@fsai/shared-ui';
<section className="rounded-xl border border-alpha-default bg-gray-bg-surface shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-alpha-default px-4 py-3">
<div>
<h3 className="text-base font-semibold text-strong">
Performance Over Time
</h3>
<p className="text-md text-subtle">
Daily {metricLabel} vs the previous {rangeLen} days
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<SegmentedControl
value={trendMetric}
size="sm"
onChange={setTrendMetric}
options={TREND_METRICS.map((m) => ({ value: m, label: META[m].label }))}
/>
<Toggle
size="sm"
checked={showPrev}
onChange={setShowPrev}
label="vs Previous Period"
/>
</div>
</div>
<div className="px-4 pb-4 pt-3">
<SeriesLegend
series={[
{ label: 'Current period', color: chartAccentColor },
...(showPrev
? [{ label: 'Previous period', color: chartMutedColor }]
: []),
]}
/>
<div className="mt-2">
<TrendChart
current={trendCur}
previous={trendPrev}
showPrevious={showPrev}
dayLabel={dayLabel}
formatValue={fmtCompact}
/>
</div>
</div>
</section>Conventions:
- One legend per chart.
TrendChartdisablesLineChart's automatic legend because the period comparison supplies its own labeledSeriesLegendbeside the compare toggle. Two legends on one chart is worse than none. - The metric switcher is a
SegmentedControlin the section header,size="sm". The chart never knows the switcher exists — the header owns it. - The comparison series uses
chartMutedColor; the current series useschartAccentColor(or the section'sChartThemehue). Never invent comparison colors. dayLabel(index, which)lets the tooltip say "Nov 3" for current and "Oct 27" for previous at the same index — that alignment is the whole point of a period comparison.
For plot-only sections without custom header controls, use ChartCard as the frame:
import { BarChart, ChartCard } from '@fsai/shared-ui';
<ChartCard title="Sales" color="blue">
<BarChart
categories={months}
series={[{ key: 'sales', label: 'Sales', values }]}
formatValue={fmtCompact}
/>
</ChartCard>ChartCard's color sets the title Badge — pair it with the chart's hue so the badge and the plot agree.
Ranked Breakdowns: Bars in Rows
For "which category is winning" questions, use label-led bars, not pies:
import { HBarRow } from '@fsai/shared-ui';
<div className="flex flex-col gap-3">
{formats.map((row) => (
<HBarRow
key={row.format}
label={`${formatLabel(row.format)} (${row.posts})`}
value={row.engagementRate}
max={Math.max(1, ...formats.map((f) => f.engagementRate))}
display={`${row.engagementRate.toFixed(1)}%`}
/>
))}
</div>Or let HBarChart own the ceiling: <HBarChart rows={rows} /> (pass max={100} for percentages).
Inside richer list rows — leaderboards, channel lists — compose the micro marks directly: MagnitudeBar under the primary number for proportion, Delta at the row end for change. Keep numbers tabular-nums and right-aligned.
Timing: Heatmap
import { Heatmap } from '@fsai/shared-ui';
<Heatmap grid={heatGrid} unit="engagements" />gridisnumber[7][24], Sunday-indexed by weekday (display order is Monday-first; the component handles the reordering).unitis a plural lowercase noun ("engagements", "clicks") — it feeds the tooltip and each cell's accessible name.- Don't render a heatmap on thin data — it needs roughly two full weekly cycles. Below that, show the section empty state instead.
Cross-Filtering
Breakdown rows are filter toggles for the whole dashboard.
- Clicking a channel row or leaderboard row filters every section to that channel/account; clicking again clears it.
- The active row shows an inline chip ("Filtering · click to clear") and an
activebackground; toggles setaria-pressed. - When any cross-filter is active, render the sticky active-filter bar: one removable
Badgechip per active filter (color="blue",onRemove), a plain-language prefix ("Filtering the whole dashboard to"), and a trailing "Reset All Filters" action.
<Badge
color="blue"
label={platformLabel}
Icon={PlatformIcon}
onRemove={() => clearPlatform(platform)}
/>Filter state lives in the view, not in the charts — chart components stay pure renderers of the filtered data.
States
Every analytics view handles four states, and the Chart Kit standardizes the inner two:
Loading
- Whole view: a skeleton that mirrors the final layout — same KPI grid, same section blocks:
<div className="flex flex-col gap-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-[88px] w-full rounded-xl" />
))}
</div>
<Skeleton className="h-[320px] w-full rounded-xl" />
<div className="grid gap-5 xl:grid-cols-2">
<Skeleton className="h-[240px] w-full rounded-xl" />
<Skeleton className="h-[240px] w-full rounded-xl" />
</div>
</div>- Inside a chart body:
ChartLoading(heightmatches the chart it replaces). Never invent per-chart spinner holes.
Empty
- Inside a chart body:
ChartEmpty— defaults say "No data for this range / Widen the date range or clear a filter." Overridemessage/hintper context. - Whole view with active filters: an
EmptyStatewhose primary action resets the filters ("Not enough data for this selection" → "Reset All Filters"). - Per section: a small
EmptyStatewith an icon and a one-line explanation of what will appear once data syncs.
Error
ErrorState with a retry action, at the level where the query failed:
<ErrorState>
<ErrorState.Icon />
<ErrorState.Title>Could not load analytics</ErrorState.Title>
<ErrorState.Description>
Something went wrong while loading analytics for this selection.
</ErrorState.Description>
<ErrorState.Actions>
<ErrorState.Action role="button" onClick={retry}>
Try again
</ErrorState.Action>
</ErrorState.Actions>
</ErrorState>See the Loading States and Error Handling patterns for the platform-wide rules these follow.
Color Rules
Chart color is a token, never a hex. The full token set is documented in Foundations → Colors.
- Single-series charts take a
ChartTheme('blue' | 'green' | 'black' | 'orange' | 'red') and use its one strong hue. Pair it with the framingBadge/ChartCardcolor so the two agree. - Multi-series charts walk
chartSeriesColorsin order — never hand-pick hues per series. The fixed order is what makes the same category land on the same color on every page.BarChartdoes this automatically when a series has nocolor. - Comparisons use
chartMutedColorfor the previous period. - Density (heatmap cells, choropleth fills) uses
chartHeatRamp; "no data" cells usechartEmptyFill, which is distinct from the ramp's lightest step. - All tokens resolve against CSS variables, which is what makes dark mode work without any JS.
Guidelines
- Do keep the band order from Page Anatomy — filters, KPIs, primary trend, breakdowns, specialized sections
- Do pick the smallest tier that answers the question — a
Deltain a row beats a chart section - Do pre-format values (
fmtCompactstyle) and keep numeralstabular-nums - Do use
ChartEmpty/ChartLoadinginside chart bodies instead of custom holes - Do give every section its own empty state, and give the whole view a reset-filters empty state
- Do put metric switchers in the section header (
SegmentedControl,size="sm"), never inside the plot - Do use
upIsGood={false}onDeltafor metrics where down is good - Do make cross-filter toggles obvious: chip on the active row, sticky filter bar, reset action
- Don't wrap
PieChartorGeoChartinChartCard— they frame themselves - Don't render two legends on one chart — the comparison legend replaces the automatic one
- Don't hard-code chart colors or invent comparison palettes — use the chart tokens
- Don't use a pie for more than five categories — use
HBarChart - Don't reserve layout space for a
Sparkline— it legitimately returnsnullon thin data - Don't draw a heatmap with less than two weekly cycles of data