Components · Tables and data viz

Chart

Six chart types — line, area, bar, stacked bar, pie, donut — wrapped around d3-scale, d3-shape, and d3-array. Styled with Flatpay's chart palette and typeset rules. Renders to SVG for accessibility, scales fluidly to its container, and tops out at the eight categorical colors before stepping to the second weight.

Documentedby Derek Fidler
€0.0K€10.0K€20.0K€30.0K€40.0K€50.0K€60.0K23 Apr25 Apr27 Apr29 Apr1 MayPayouts

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.

€0€10.0K€20.0K€30.0K€40.0K€50.0K€60.0K21 Apr23 Apr25 Apr27 Apr29 Apr1 MayPayouts

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.

€0€10.0K€20.0K€30.0K€40.0K€50.0K€60.0K21 Apr23 Apr25 Apr27 Apr29 Apr1 MayPayouts

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.

€0€20.0K€40.0K€60.0K€80.0K€100.0K€120.0K21 Apr23 Apr25 Apr27 Apr29 Apr1 MayAmount
  • 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.

024681012IdeaBuildingDemo-readyArchivedKilledPrototypes

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.

05101520253035Q1Q2Q3Q4Prototypes
  • 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.

020406080Q1Q2Q3Q4Prototypes
  • 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.

€0K€10K€20K€30K€40K€50K€60K26 Apr27 Apr28 Apr29 Apr30 Apr1 May2 MayPayouts
  1. 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.

  2. Y-axis labels

    Set in Martian Mono with tabular numerals so digits line up across rows. Token: chart.label (text.tertiary).

  3. Series

    Line, area fill, or bars — coloured from the categorical palette in series order.

  4. Gridlines

    1 px horizontal rules at every y-tick. Token: chart.neutral (#DEDEDE).

  5. X-axis labels

    Set in Inter Tight. d3-time-format provides the default time format; pass `xFormat` to override.

  6. 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. curveMonotoneX from 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) plus width: 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

  • ariaLabel is 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

02468101214Q1Q2Q3
  • A
  • B
  • C

Do

Six series or fewer per chart. Beyond that, split into small multiples.

051015Q1Q2Q3

Don't

Don't pile eight lines on one axis — even with the full categorical palette.

05101520253035Q1Q2Q3Q4

Do

Y-axis starts at zero so the trend reads honestly.

010.0K20.0K30.0K40.0K50.0K60.0K26 Apr26 Apr27 Apr27 Apr28 Apr28 Apr29 Apr29 Apr30 Apr

Don't

Don't write 'Line chart' in the ariaLabel. Describe what the chart says.

Props

PropTypeDefaultDescription
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.
seriesChartSeries[]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.
segmentsChartSegment[]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.
heightnumber260Chart height in pixels. Width is fluid — fills the parent.
yFormat(v: number) => stringFormat function for y-tick labels and tooltips. Defaults to a K/M-suffix format.
xFormat(v: number | Date) => stringFormat function for x-tick labels (linear / time only). Defaults to d3-time-format's '%-d %b'.
yLabelReactNodeOptional ALL-CAPS eyebrow above the y-axis. Use it when the unit isn't obvious.
ariaLabel*stringRequired. Describes what the chart communicates — read by screen readers.
showGridbooleantrueToggle horizontal gridlines.
showLegendbooleanToggle the legend below the chart. Defaults to true when there are 2+ series.