Daily payouts · line
Overview
Chart is a thin wrapper around d3 — we use d3-scale for the axes, d3-shape for the line and area generators, and d3-array / d3-time-format for domain math and tick labels. The visual layer is owned by Flatpay tokens: every series gets a colour from the categorical palette, gridlines and axes use the chart neutral / axis tokens, and labels are set in mono numerals on the y-axis and Inter Tight on the x.
d3, not Recharts / Visx / Chart.js
d3-scale and d3-shape are tree-shaken pure-function modules — no runtime, no virtual DOM, ~10 kB gzipped each. We render the output ourselves in plain SVG, which keeps the chart server-side renderable and stops third-party styling from leaking in.
Line
The default. Reach for a line when the trend matters more than the absolute magnitude — daily payouts, weekly active stores, rolling 28-day errors. Endpoint dots reinforce every data point.
type="line"
Single series, time-series x-axis. Endpoint dots reinforce each data point — used when the trend matters more than the magnitude.
Area
Same generator as line, plus a 16% opacity fill below. Reach for area when the volume beneath the line is the story — cumulative totals, monthly turnover, deposit balances at rest.
type="area"
Same shape as line; adds a 16% opacity fill below. Reach for it when the area volume tells a story (cumulative, totals).
Multiple series
Pass two or more series; each gets the next categorical colour token. Six series is the practical ceiling — beyond that the eye can't track which line is which without hunting in the legend.
- Revenue
- Fees
Multiple series
Two or more series, each at its own categorical color (token 1, 2, ...). The legend below names them.
Bar
Categorical x-axis, single series. Bars are 75% of each category's slot wide, with 25% padding so the eye reads them as discrete groups rather than a stacked block.
type="bar"
Categorical x-axis, single series. Best when the categories are nominal — statuses, countries, owners.
Grouped bar
Multiple series on the same categorical x-axis — each series gets a sub-bar inside every category. Useful for breakdowns: prototypes per team per quarter, transactions per channel per market.
- Merchant
- Platform
- AI
Grouped bars
Multiple series on the same categorical x-axis. Bars share each category's slot with sub-width spacing.
Stacked bar
Same data shape as grouped bar; the series stack inside one bar per category instead of sitting side-by-side. Reach for stacked when the total is the headline and the breakdown is supporting detail. The stack uses d3-shape's stack() generator with no offset (zero-baseline) and series-order layering.
- Merchant
- Platform
- AI
type="stackedBar"
Multiple series stacked into one bar per category. Use when the total per category is the headline and the per-series breakdown is the supporting detail.
Pie
Single-level part-of-whole. Pass segments instead of series; the order of the array determines the order around the circle starting at 12 o'clock. Three to five segments is the comfortable range — the eye stops disambiguating slice areas past five.
- Card8.48868%
- Cash3.24526%
- Other7496%
type="pie"
Single-level pie. Reach for it when the part-of-whole story is the point — three to five segments at most. Beyond that the eye loses the area comparison.
No labels on slices
The slice itself never carries a label or percentage — the legend below names every segment with its value and share of the total. On-slice labels are unreliable at small sizes and force a second type style inside the chart; we keep the radial graphic clean and push the words to the legend table.
Donut
Same generator, with a 62 % inner radius. Use a donut when the centre will carry a hero number (the total), a label (“Total payouts”), or a brand mark — never as a stylistic flourish on top of a pie that didn't need it.
- Card8.48868%
- Cash3.24526%
- Other7496%
type="donut"
Same data as pie, with a 62% inner radius. Use when the centre is doing work — a hero number, a label, or a brand mark.
Palette
Eight categorical colours from the lead 600 weight of the brand ramps, in the order series get assigned. Beyond eight, the system steps to the 800 weight (categorical 9–16) and then 900 (17–24). See Data visualization for the rule.
chart.categorical.1
#7190FF
chart.categorical.2
#00B26F
chart.categorical.3
#B37AFF
chart.categorical.4
#F560B0
chart.categorical.5
#E47D00
chart.categorical.6
#AC9900
chart.categorical.7
#00A9C8
chart.categorical.8
#F2645C
Six per chart, not eight
The palette has eight, but a single chart should rarely show more than six series. Beyond six the human eye stops disambiguating colours; switch to small multiples or move the slowest-changing series into a sibling chart.
Anatomy
Six named parts. Margins are fixed inside the SVG viewBox so the chart math is stable; the SVG itself scales to its parent.
Plot area
Inner SVG region. Fixed margins (16/16/32/56) leave room for axes and y-label without forcing the chart to recompute on resize.
Y-axis labels
Set in Martian Mono with tabular numerals so digits line up across rows. Token: chart.label (text.tertiary).
Series
Line, area fill, or bars — coloured from the categorical palette in series order.
Gridlines
1 px horizontal rules at every y-tick. Token: chart.neutral (#DEDEDE).
X-axis labels
Set in Inter Tight. d3-time-format provides the default time format; pass `xFormat` to override.
Y-label eyebrow
Optional ALL-CAPS eyebrow above the y-axis. Use it to name the unit (
PAYOUTS,REVENUE) when the y-axis numbers alone aren't self-explanatory.
Behavior
- Y-axis starts at zero. Unless the data goes negative, the y-axis anchors at 0 — never clip the bottom to inflate trends. Quartz's rule: a chart that starts at 90% can make any number look dramatic.
- Curves are monotone, not bezier.
curveMonotoneXfrom d3-shape — the line passes through every data point and never overshoots. No cardinal smoothing that pretends data exists between samples. - Endpoint dots cap at 32 points. Beyond that, dots crowd the line and turn into noise. The line speaks for itself for hourly + minute resolution data.
- SVG scales, not redraws. The chart uses a fixed
viewBox(800 px wide) pluswidth: 100%. The browser handles resize; we don't debounce, we don't recompute, the math is stable. - No tooltips yet. Hover detail comes in the next iteration. For now, the legend and axis labels carry the load — design surfaces accordingly.
Accessibility
ariaLabelis required. Charts are images to screen readers — the label is the only thing they read. Write it to describe what the chart communicates, not what it contains: “Daily payouts trending up” not “Line chart with 14 points”.- Pair with a tabular fallback when the data is the answer. For dashboards where users need exact numbers (settlement totals, invoice rows), render a screen-reader-only table next to the chart. The chart shows the trend; the table answers “what was Tuesday?”.
- Don't carry meaning in colour alone.Every series in the legend is named. If a series name implies status ( “On time”, “Delayed”), say it in words too — colour-blind users shouldn't have to guess from hue.
- Contrast. Categorical colours are calibrated to sit on the chart background (off-white) at WCAG AA for non-text UI; line strokes are 1.75 px so they stay legible at small sizes.
Code
Import from @flatpay-dk/ui. Pass series with stable ids; the chart will pick categorical colours in series order unless you override per-series with color.
tsx
import { Chart } from "@flatpay-dk/ui";
// Simple line — single series, time-series x-axis
<Chart
type="line"
xType="time"
ariaLabel="Daily payouts trending up over the last two weeks"
yLabel="Payouts"
yFormat={(v) => `€${(v / 1000).toFixed(1)}K`}
series={[
{
id: "payouts",
label: "Daily payouts",
data: [
{ x: new Date("2026-04-22"), y: 41800 },
{ x: new Date("2026-04-24"), y: 47200 },
{ x: new Date("2026-04-26"), y: 49800 },
],
},
]}
/>
// Area chart — same generator, plus a 16% opacity fill
<Chart type="area" xType="time" series={[…]} ariaLabel="…" />
// Multi-series — auto-coloured from the categorical palette
<Chart
type="line"
xType="time"
ariaLabel="Revenue versus fees"
series={[
{ id: "revenue", label: "Revenue", data: [...] },
{ id: "fees", label: "Fees", data: [...] },
]}
/>
// Bar — categorical x-axis
<Chart
type="bar"
xType="category"
ariaLabel="Prototypes by status"
series={[
{
id: "count",
label: "Prototypes",
data: [
{ x: "Idea", y: 4 },
{ x: "Building", y: 7 },
{ x: "Demo-ready", y: 12 },
],
},
]}
/>
// Grouped bar — multiple series on the same categories
<Chart
type="bar"
xType="category"
ariaLabel="Prototypes shipped by team, by quarter"
series={[
{ id: "merchant", label: "Merchant", data: [...] },
{ id: "platform", label: "Platform", data: [...] },
{ id: "ai", label: "AI", data: [...] },
]}
/>
// Stacked bar — same series shape, stacked into one bar per category
<Chart
type="stackedBar"
xType="category"
ariaLabel="Prototypes shipped per quarter, stacked by team"
series={[…]}
/>
// Pie / donut — pass segments instead of series
<Chart
type="donut"
ariaLabel="Payment type breakdown"
segments={[
{ id: "card", label: "Card", value: 8488 },
{ id: "cash", label: "Cash", value: 3245 },
{ id: "other", label: "Other", value: 749 },
]}
/>Best practices
- A
- B
- C
Do
Six series or fewer per chart. Beyond that, split into small multiples.
Don't
Don't pile eight lines on one axis — even with the full categorical palette.
Do
Y-axis starts at zero so the trend reads honestly.
Don't
Don't write 'Line chart' in the ariaLabel. Describe what the chart says.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "line" | "area" | "bar" | "stackedBar" | "pie" | "donut" | "line" | Visual type. Cartesian types (line, area, bar, stackedBar) consume `series`; radial types (pie, donut) consume `segments` instead. |
| series | ChartSeries[] | — | Cartesian types only. One entry per series; each has an id, label (used in legend + aria), and a data array of { x, y } points. Optional per-series color override. |
| segments | ChartSegment[] | — | Pie / donut only. Array of { id, label, value } in the order they should render around the circle starting at 12 o'clock. |
| xType | "linear" | "time" | "category" | — | Override the x-axis interpretation. Defaults to "category" for bar, "linear" otherwise. |
| height | number | 260 | Chart height in pixels. Width is fluid — fills the parent. |
| yFormat | (v: number) => string | — | Format function for y-tick labels and tooltips. Defaults to a K/M-suffix format. |
| xFormat | (v: number | Date) => string | — | Format function for x-tick labels (linear / time only). Defaults to d3-time-format's '%-d %b'. |
| yLabel | ReactNode | — | Optional ALL-CAPS eyebrow above the y-axis. Use it when the unit isn't obvious. |
| ariaLabel* | string | — | Required. Describes what the chart communicates — read by screen readers. |
| showGrid | boolean | true | Toggle horizontal gridlines. |
| showLegend | boolean | — | Toggle the legend below the chart. Defaults to true when there are 2+ series. |