FSAI Design System
Patterns

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 via upIsGood, not by sign — a falling cost is good.
  • Sparkline — the shape of a series, no scale. Returns null when 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. HBarChart owns the shared ceiling so callers stop recomputing Math.max(...) per list.

Tier 2 — The plot system (one axis language)

  • LineChart and BarChart render a plot and nothing else.
  • TrendChart is LineChart with a comparison series — current vs previous period.
  • ChartCard supplies 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 a title and renders its own card with a list view.
  • GeoChart — US or Canada choropleth. Also self-framing with title, 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:

  1. Filter row — date range, scope/segment dropdowns, "Updated X ago", refresh.
  2. Active-filter bar — sticky, only rendered when cross-filters are active. Removable Badge chips + "Reset All Filters".
  3. KPI row — a responsive grid of StatTiles. The headline numbers.
  4. Insights strip — optional short prose takeaways ("This period at a glance").
  5. Primary trend — the one big time-series section with a metric switcher and period comparison.
  6. Breakdown sections — channel/category performance, leaderboards, ranked lists, in two-up grids.
  7. 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:

  • value is a pre-formatted string — use fmtCompact style formatting (12.4K), not raw numbers.
  • Delta handles 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.
  • StatTile accepts onClick + active and 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. TrendChart disables LineChart's automatic legend because the period comparison supplies its own labeled SeriesLegend beside the compare toggle. Two legends on one chart is worse than none.
  • The metric switcher is a SegmentedControl in the section header, size="sm". The chart never knows the switcher exists — the header owns it.
  • The comparison series uses chartMutedColor; the current series uses chartAccentColor (or the section's ChartTheme hue). 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" />
  • grid is number[7][24], Sunday-indexed by weekday (display order is Monday-first; the component handles the reordering).
  • unit is 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 active background; toggles set aria-pressed.
  • When any cross-filter is active, render the sticky active-filter bar: one removable Badge chip 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 (height matches 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." Override message/hint per context.
  • Whole view with active filters: an EmptyState whose primary action resets the filters ("Not enough data for this selection" → "Reset All Filters").
  • Per section: a small EmptyState with 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 framing Badge/ChartCard color so the two agree.
  • Multi-series charts walk chartSeriesColors in order — never hand-pick hues per series. The fixed order is what makes the same category land on the same color on every page. BarChart does this automatically when a series has no color.
  • Comparisons use chartMutedColor for the previous period.
  • Density (heatmap cells, choropleth fills) uses chartHeatRamp; "no data" cells use chartEmptyFill, 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 Delta in a row beats a chart section
  • Do pre-format values (fmtCompact style) and keep numerals tabular-nums
  • Do use ChartEmpty/ChartLoading inside 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} on Delta for metrics where down is good
  • Do make cross-filter toggles obvious: chip on the active row, sticky filter bar, reset action
  • Don't wrap PieChart or GeoChart in ChartCard — 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 returns null on thin data
  • Don't draw a heatmap with less than two weekly cycles of data

On this page