FSAI Design System
Components

Charts

The Chart Kit — tiered chart components from in-row micro marks to full plots, with shared states and token-based theming.

Overview

The Chart Kit is the platform's single chart language. It replaces the previous Nivo-based charts with lightweight, token-themed components organized in three tiers:

  • Tier 1 — micro marks that live inside a row or a tile: Delta, Sparkline, MagnitudeBar, HBarChart
  • Tier 2 — one plot system with a shared axis language: LineChart, BarChart, TrendChart, framed by ChartCard
  • Tier 3 — specialized forms: GeoChart, Heatmap, PieChart
  • Shared states: ChartEmpty, ChartLoading
  • Theme tokens: chartTheme exports — colors are tokens, never hexes

All parts import from @fsai/shared-ui. For how to compose them into a full dashboard — KPI rows, metric switchers, cross-filtering, section states — see the Analytics Display pattern. This page is the API reference.

import {
  ChartCard,
  LineChart,
  BarChart,
  TrendChart,
  Delta,
  Sparkline,
  MagnitudeBar,
  HBarChart,
  GeoChart,
  Heatmap,
  PieChart,
  ChartEmpty,
  ChartLoading,
  SeriesLegend,
  chartAccentColor,
  chartSeriesColors,
} from '@fsai/shared-ui';

Tier 1: Micro Marks

Micro marks render inside a row, cell, or stat tile. They have no axes, no legend, no card, and no title of their own — the surrounding row supplies the context.

Delta

Change against the previous period.

<Delta value={12.4} />
<Delta value={-3.2} suffix="pp" />
<Delta value={cost.delta} upIsGood={false} />
<Delta value={null} /> {/* renders "New" */}
PropTypeDefaultDescription
valuenumber | nullPercent (or suffix-unit) change. null means no prior period — renders "New".
suffixstring'%'Unit appended to the number.
upIsGoodbooleantrueDirection is colored by intent, not sign — pass false for costs, churn, unsubscribes.
classNamestringAdditional classes.

Sparkline

The shape of a series with no scale. Use inside a stat tile; never as the only chart answering a question.

<Sparkline points={dailyImpressions} />
PropTypeDefaultDescription
pointsnumber[]The series values, in order.
widthnumber96SVG width in px.
heightnumber28SVG height in px.
colorstringchartAccentColorStroke token.

Returns null on thin data — fewer than 2 points, or fewer than 3 non-zero values — because a flat line with one spike reads as broken rather than as data. Treat "no sparkline" as a valid outcome; don't reserve space for it.

MagnitudeBar

One value against a known ceiling, inside a table cell or list row. Reads as a proportion, so it carries no axis or ticks.

<MagnitudeBar value={row.followers} max={maxFollowers} />
PropTypeDefaultDescription
valuenumberThe value to fill.
maxnumberThe shared ceiling. Non-zero values keep a 2% minimum width so they never vanish.
colorstringchartAccentColorFill token.
trackColorstringchartAccentSoftColorTrack token.

HBarChart / HBarRow

A ranked comparison where the label leads and the bar compares. Beats a pie for more than five categories.

<HBarChart
  rows={formats.map((f) => ({
    label: f.name,
    value: f.rate,
    display: `${f.rate.toFixed(1)}%`,
  }))}
  max={100}
/>

HBarChart props:

PropTypeDefaultDescription
rowsHBarChartRow[]{ label: string; value: number; display: string } per row.
maxnumberlargest row valueFix the ceiling — e.g. 100 for percentages.

display is the pre-formatted string; value stays numeric so the bar can scale. Use HBarRow directly (label, value, max, display) when rows need custom placement inside richer list rows.


Tier 2: The Plot System

LineChart and BarChart render a plot and nothing else — no card, no title. ChartCard supplies the frame. All plots share the same axis treatment, tooltip shape, and niceTicks scale so the axis reads the same whether the mark is a line or a bar.

ChartCard

The frame a Tier 2 chart sits in: a title Badge, optional header slots, border and padding.

<ChartCard title="Leads" color="blue" right={<DateRangeControl />}>
  <BarChart categories={months} series={series} formatValue={fmtCompact} />
</ChartCard>
PropTypeDefaultDescription
titlestringRendered as a Badge. Omit when supplying headerStart.
colorChartTheme'blue'Badge color. Pair it with the chart's hue so the two agree.
headerStartReactNodeReplaces the title badge — e.g. to fold a metric switcher into the header.
rightReactNodeTrailing header slot for controls opposite the title.
childrenReactNodeThe plot.
classNamestringAdditional classes on the card.

LineChart

<LineChart
  series={[
    { key: 'leads', label: 'Leads', values, color: chartAccentColor, area: true },
  ]}
  xLabel={(i) => dayLabels[i]}
  formatValue={fmtCompact}
/>
PropTypeDefaultDescription
seriesChartSeries[]See series shape below.
xLabel(index: number) => stringLabel for each x slot (axis + tooltip heading).
formatValue(value: number) => stringFormats axis ticks and tooltip values.
heightnumber240Plot height in px.
seriesLabelAt(seriesKey: string, index: number) => stringPer-series tooltip label at an index — how TrendChart shows "Nov 3" vs "Oct 27" at the same slot.
legendbooleanautoDefaults on when there is more than one series. Pass false when supplying your own SeriesLegend.
ariaLabelstring'Line chart'Accessible name for the plot.

ChartSeries shape:

FieldTypeDescription
keystringStable identity.
labelstringLegend and tooltip label.
valuesnumber[]One value per x slot.
colorstringA chart color token.
areabooleanThe area wash. Only the primary series gets one — two washes muddy each other.
mutedbooleanRendered thinner and behind, for comparison series.

BarChart

<BarChart
  categories={['Jan', 'Feb', 'Mar']}
  series={[
    { key: 'new', label: 'New', values: [12, 18, 9] },
    { key: 'returning', label: 'Returning', values: [7, 11, 14] },
  ]}
  stacked
  formatValue={fmtCompact}
/>
PropTypeDefaultDescription
categoriesstring[]One entry per x-axis slot.
seriesBarChartSeries[]Like ChartSeries but color is optional — omitted colors walk chartSeriesColors in order (the multi-series rule).
stackedbooleanfalseStack segments to a per-category total instead of grouping.
formatValue(value: number) => stringFormats ticks and tooltip values.
heightnumber240Plot height in px.
ariaLabelstring'Bar chart'Accessible name.

TrendChart

Not a chart of its own — LineChart with a comparison series, so every change to the line language lands in both.

<TrendChart
  current={vm.trendCur}
  previous={vm.trendPrev}
  showPrevious={showPrev}
  dayLabel={vm.dayLabel}
  formatValue={fmtCompact}
/>
PropTypeDefaultDescription
currentnumber[]Current-period series (accent color, area wash).
previousnumber[]Previous-period series (muted, behind).
showPreviousbooleanToggle the comparison series.
dayLabel(index: number, which: 'current' | 'previous') => stringSame index, two periods — what makes the comparison readable.
formatValue(value: number) => stringFormats ticks and tooltip values.
heightnumber240Plot height in px.
ariaLabelstring'Trend over time, current versus previous period'Accessible name.

TrendChart sets legend={false} — the period comparison is the one case where the caller supplies a labeled SeriesLegend of its own, usually beside the compare toggle.

SeriesLegend

<SeriesLegend
  series={[
    { label: 'Current period', color: chartAccentColor },
    { label: 'Previous period', color: chartMutedColor },
  ]}
/>
PropTypeDescription
seriesArray<{ label: string; color: string }>Legend entries.
childrenReactNodeTrailing content on the legend row.

niceTicks(max) is also exported — the 1/2/2.5/5/10-stepped tick scale the plots share — for the rare custom plot that must match the axis language.


Tier 3: Specialized Charts

These answer a different shape of question and are self-framing — they take a title and render their own card and list view. Do not wrap them in ChartCard.

PieChart

Composition of a small set. Use for five or fewer slices; ranked lists beat pies beyond that.

<PieChart
  data={sources.map((s) => ({ label: s.name, value: s.count }))}
  title="Lead Sources"
  valueHeader="Leads"
  isLoading={isLoading}
/>
PropTypeDefaultDescription
dataArray<{ value: number; label: string }>The slices.
titlestringCard title.
labelHeaderstring'Label'List-view label column header.
valueHeaderstring'Count'List-view value column header.
modifiersArray<{ label; isActive; onToggle }>Header toggle chips (e.g. "Include closed").
isLoadingbooleanRenders the loading state.
formatValue(value: number) => stringtoLocaleStringValue formatting.

GeoChart

US or Canada choropleth with a list view.

<GeoChart
  data={rows.map((r) => ({ state: r.state, value: r.leads }))}
  formatValue={fmtCompact}
  title="Leads by State"
  valueLabel="Leads"
/>
PropTypeDefaultDescription
country'us' | 'ca''us'Which map to draw. Data rows for the other country are simply not on the map.
dataArray<{ state: State; value: number }>State is the SDK type and spans both countries.
formatValue(value: number) => string | numberValue formatting.
titlestringCard title.
valueLabelstringHeader for the value column in list view — "Leads", "Locations".
isLoadingbooleanRenders the loading state.

Heatmap

When, across a week, activity lands.

<Heatmap grid={heatGrid} unit="engagements" />
PropTypeDefaultDescription
gridnumber[][]number[7][24] — summed per weekday (Sunday-indexed) and hour. Display order is Monday-first; the component reorders.
unitstring'events'What a cell counts. Plural lowercase noun — feeds the tooltip and cell accessible names.
dayLabelsstring[]['Sun', …]Day names, Sunday-indexed, for locales or shorter labels.

Cells use chartHeatRamp; zero-value cells use chartEmptyFill. Needs about two full weekly cycles of data to be worth drawing.


States

One spinner, one empty, everywhere — instead of each chart inventing its own hole.

{isLoading ? (
  <ChartLoading height={240} />
) : hasData ? (
  <LineChart height={240} />
) : (
  <ChartEmpty height={240} />
)}

ChartLoading props: height (default 256).

ChartEmpty props:

PropTypeDefault
heightnumber256
messagestring'No data for this range'
hintstring'Widen the date range or clear a filter.'

Match height to the chart being replaced so sections don't jump between states.


Theme Tokens

Chart color is a token, never a hex. All tokens resolve against the platform's CSS variables, which is what makes dark mode work without any JS. Exported from @fsai/shared-ui:

ExportUse
ChartTheme (type)'blue' | 'green' | 'black' | 'orange' | 'red' — the single-series hues, a subset of BadgeColor so chart and badge colors pair.
chartMainColorByTheme / chartSoftColorByThemeStrong hue and its background wash per theme.
chartAccentColor / chartAccentSoftColorThe default accent (blue) and its wash.
chartMutedColorThe comparison-series gray.
chartSeriesColorsNine ordered multi-series colors — walk in order, never hand-pick, so the same category lands on the same color everywhere.
chartHeatRampSeven monotonic steps for density (heatmap cells, choropleth fills).
chartEmptyFillThe "no data" cell color, distinct from the ramp's lightest step.
chartGridColor / chartCrosshairColor / chartSurfaceColorFrame parts shared by every cartesian chart.

See Foundations → Colors for how these resolve to semantic color variables.


Guidelines

  • Do pick the smallest tier that answers the question — a Delta in a row beats a chart section
  • Do frame Tier 2 plots with ChartCard and pair its color with the chart's hue
  • Do use ChartEmpty/ChartLoading with a height matching the chart they replace
  • Do let multi-series BarChart colors default to chartSeriesColors order
  • Do give plots a meaningful ariaLabel and pre-formatted values via formatValue
  • Don't wrap PieChart or GeoChart in ChartCard — they frame themselves
  • Don't hard-code hex colors or invent new series palettes — use the theme tokens
  • Don't put an area wash on more than one line series
  • Don't use a pie beyond five slices — use HBarChart
  • Don't build a custom plot when a Tier 2 chart fits — if one is truly needed, keep the axis language via niceTicks and the theme tokens

For composition — KPI rows, metric switchers, comparison legends, cross-filtering, section-level states — see the Analytics Display pattern.

On this page