Charting

Guidelines for building readable, accessible, and themeable charts in Sitka — covering chart anatomy, type selection, and in-depth documentation for Sankey and Alluvial flow diagrams.

Chart type selection

Start from the question the chart must answer, not from the data shape. The same dataset can be visualised in multiple ways — only one will answer the actual question directly.

Chart typeUse whenAvoid when
Bar / ColumnComparing discrete categoriesContinuous time series
LineTrends over time, continuous dataFewer than 3 data points
AreaVolume over time, stacked part-to-wholeMore than 3 series
DonutPart-to-whole with 2–4 segments5+ segments or subtle differences
ScatterCorrelations between two continuous variablesOrdinal or categorical axes
HeatmapTwo-dimensional densityPrecise value comparison
SankeyQuantified flow through a network of stagesCategorical change over time (use Alluvial)
AlluvialHow a population shifts across categories over timeNon-temporal flows between named nodes
Source AlluvialMulti-source conversion tracking through sequential pipeline stagesTime-series without a defined conversion endpoint
TreemapHierarchical part-to-whole at two levelsComparing values without hierarchy
FunnelSequential drop-off through a defined processNon-linear processes or parallel paths

Chart anatomy

Every Cartesian chart is built from the same set of elements. Keep them consistent — users should not have to re-learn where to look for the title, scale, or legend across different charts in your product.

Q1 Revenue by Region (£M)04080120AsiaEMEAAmericasANZSeries 1Series 2Series 3Series 4TitleGrid linesData seriesX-axisY-axisLegend
Title

1–8 words. State what the chart shows, not what to conclude. Keep it inside the chart boundary, not above it.

Y-axis

Include units in the axis label (£M, ms, %). Start at zero for bar charts; truncating the axis exaggerates differences.

X-axis

Date labels: abbreviate to 3 chars (Jan, Feb). Rotate only as a last resort — prefer shortening labels instead.

Grid lines

Horizontal only. 5% white opacity in dark mode, 6% black in light mode. Never more than 5 grid lines on a chart.

Data series

Use the Sitka series palette. Direct-label bars when there are ≤5 data points; use a legend for more.

Legend

Position at the top-right or bottom-center. Never on the right — it creates excessive horizontal scanning.

Sankey diagrams

A Sankey diagram shows the quantity of flow between named nodes arranged in columns. Link widths are proportional to the flow value — the wider the band, the larger the quantity moving from source to target. The total volume entering any node equals the total volume leaving it.

Traffic sources → Landing pages (1,000 sessions)

Organic Search600 sessionsPaid Ads250 sessionsReferral150 sessionsHomepage450 sessionsBlog320 sessionsPricing230 sessions

Anatomy

Node

A named entity in the flow — a source, stage, or destination. Node height is proportional to total throughput. Rendered as a vertical rectangle.

Link (band)

The curved path between two nodes. Width = flow quantity × scale. Links stack within a node without overlap — order them largest-to-smallest for readability.

Columns

Left-to-right progression of stages. Nodes in the same column are independent — they don't share flow with each other, only with nodes in adjacent columns.

Conservation

Total flow in = total flow out at every node. If a Sankey doesn't balance, either the data is wrong or the diagram needs an explicit 'loss' or 'other' node.

When to use

Use Sankey
  • Website traffic funnels (source → page → conversion)
  • Budget allocation (revenue streams → departments → projects)
  • Energy or material flow (input → process → waste / output)
  • User journey mapping across feature areas
  • Supply chain from origin to destination
Avoid Sankey when
  • You want to show change over time — use an Alluvial instead
  • Nodes are not conserved (data in ≠ data out without explanation)
  • You have more than ~6 source nodes — links become illegible
  • Precise values matter — use a table or stacked bar for comparison
  • All flows are roughly equal — widths provide no information

Implementation

SankeyChart.tsx — using d3-sankey
import { sankey, sankeyLinkHorizontal, SankeyNode, SankeyLink } from "d3-sankey";

interface RawNode { id: string; label: string; }
interface RawLink { source: string; target: string; value: number; }

export function SankeyChart({ nodes, links, width, height }: {
  nodes: RawNode[];
  links: RawLink[];
  width: number;
  height: number;
}) {
  const { nodes: laidOut, links: laidOutLinks } = sankey<RawNode, RawLink>()
    .nodeId((d) => d.id)
    .nodeWidth(16)
    .nodePadding(8)
    .extent([[0, 0], [width, height]])({ nodes, links });

  return (
    <svg width={width} height={height}>
      {laidOutLinks.map((link, i) => (
        <path
          key={i}
          d={sankeyLinkHorizontal()(link) ?? ""}
          fill="none"
          stroke={colorForSource(link.source)}
          strokeWidth={Math.max(1, link.width ?? 0)}
          strokeOpacity={0.35}
        />
      ))}
      {laidOut.map((node) => (
        <rect
          key={node.id}
          x={node.x0} y={node.y0}
          width={(node.x1 ?? 0) - (node.x0 ?? 0)}
          height={(node.y1 ?? 0) - (node.y0 ?? 0)}
          fill={colorForNode(node.id)}
        />
      ))}
    </svg>
  );
}
d3-sankey

Official D3 plugin. Full control over layout and rendering. Recommended when you need custom interaction or animation.

Nivo · Sankey

React wrapper with built-in tooltips, theming, and responsive container. Good for dashboards where speed matters more than customisation.

Swift Charts

No native Sankey support. Use a custom Shape implementation with Path and cubic bezier curves. Performance is excellent — keep to <50 links.

Alluvial diagrams

An Alluvial diagram is a specialisation of Sankey where each column represents a point in time and each row of bands represents the same category persisting across periods. The vertical position and height of a band shows the category's share; the drift between columns reveals movement — in JobFlo, applications advancing through the pipeline, stalling in a stage, or reaching terminal outcomes like Rejected or Accepted.

JobFlo application pipeline stage distribution — Jan to Mar 2025

Jan 2025WishlistAppliedScreeningFeb 2025Mar 2025WishlistAppliedScreeningInterviewWishlist ↓ 45→20%Interview ↑ 5→17%
WishlistAppliedScreeningInterview

Reading an Alluvial

Band height increases

That stage's share of the total grew between periods. The band expands upward or downward depending on position.

Band height decreases

That stage's share shrank. The most diagnostic case: Wishlist shrinking while Interview grows signals a healthy, advancing pipeline.

Band crosses another

Two tiers swapped relative size. Crossing bands are visually prominent — they signal a major shift worth annotating.

Design rules

  • Sort categories by size at the first column, largest to smallest. Maintain that order across all columns — do not re-sort between periods. Users track bands by position, not colour alone.
  • Normalise totals to 100% unless the absolute population size is the point. Percentage alluvials are easier to read because all columns have the same height.
  • Use 4 categories or fewer. Beyond that, thin bands become illegible and the diagram loses its glanceability advantage over a stacked bar chart.
  • Annotate bands that cross. A crossing is the most significant event in an alluvial — it deserves a callout note explaining what drove the change.
  • Show at most 5 time periods (columns). More columns compress the bands and make individual change unreadable. Use a stacked area chart for long time series.
  • Band opacity at 25–35% allows the overlap zone to be read as a blend. Lower opacity loses the band; higher opacity obscures the space between columns.

Implementation

AlluvialChart.tsx — SVG with d3-shape
import { linkHorizontal } from "d3-shape";

// Each band connects the same category across two adjacent columns.
// left / right describe the vertical span in the column.
interface Band {
  category: string;
  color: string;
  left:  { y0: number; y1: number };  // top and bottom y in left column
  right: { y0: number; y1: number };  // top and bottom y in right column
}

function ribbonPath(
  lx: number, ly0: number, ly1: number,
  rx: number, ry0: number, ry1: number,
): string {
  const cx = (lx + rx) / 2;
  return [
    `M ${lx} ${ly0}`,
    `C ${cx} ${ly0} ${cx} ${ry0} ${rx} ${ry0}`,
    `L ${rx} ${ry1}`,
    `C ${cx} ${ry1} ${cx} ${ly1} ${lx} ${ly1}`,
    "Z",
  ].join(" ");
}

export function AlluvialChart({ bands, lx, rx }: {
  bands: Band[];
  lx: number;  // right edge of left column
  rx: number;  // left edge of right column
}) {
  return (
    <>
      {bands.map((band) => (
        <path
          key={band.category}
          d={ribbonPath(lx, band.left.y0, band.left.y1, rx, band.right.y0, band.right.y1)}
          fill={band.color}
          fillOpacity={0.3}
        />
      ))}
    </>
  );
}

Pipeline flow alluvial

A source alluvial extends the standard alluvial by splitting each stage column into coloured source strands — one per acquisition channel or origin. Instead of showing how a single population shifts over time, it answers: which sources contribute most at each stage, and where do they fall off? The chart reads left-to-right; strand thickness at each column is proportional to the volume from that channel at that stage.

Conversion funnel — volume by acquisition channel across lifecycle stages

Aware840Engaged312Qualified94Proposed41ConvertedRetained18% churnedOrganicReferralPaidDirectPartner

Anatomy

Stage column

Each vertical column represents one stage in the lifecycle. The label and a colour-coded volume count sit above the stream area. Stage colour matches the product's pipeline colour system.

Source strand

Each strand is a single acquisition source (e.g. LinkedIn, Greenhouse). Strands are stacked vertically and connected with bezier curves across columns. Thickness is proportional to candidate count at that stage.

Active-stage crosshair

A white vertical line marks the currently selected or highlighted stage. White dots at each strand's midpoint confirm the intersection point and provide hover targets.

Terminal badge

A red pill badge in the top-right corner shows the total rejection count for the visible period. Use a neutral grey badge for non-terminal outcomes like withdrawals.

Design rules

  • Assign each source a distinct hue from the product's extended palette. Do not reuse stage colours for sources — stage colour encodes position in the pipeline; source colour encodes origin.
  • Stack sources consistently by volume at the first stage (largest to smallest, top to bottom). Never reorder strands between columns — users track strands by position across the diagram.
  • Use opacity 0.75–0.85 for strand fills. Full opacity occludes overlapping strands and removes the sense of depth. Too-low opacity makes thin strands disappear.
  • Keep the crosshair subtle (1px white, 80% opacity). It should be readable against the dark background but not compete with the strands for visual weight.
  • Show 5–7 sources maximum. Beyond that, thin strands at late stages become indistinguishable. Group small sources into an 'Other' strand.
  • Always pair the chart with a text summary of the key finding — e.g. 'Referral drives 22% of inbound but converts at 3× the rate of Paid.' The chart alone is not accessible.

Use when

  • You have 3–7 distinct sources feeding a multi-stage pipeline
  • The question is about source quality, not just volume
  • Stakeholders need to see both drop-off and source mix in one view

Avoid when

  • You have only one source — use a plain funnel instead
  • Stage counts are equal (no conversion) — nothing to show
  • You need precise numeric comparison — use a grouped bar chart

Implementation

PipelineFlowChart.tsx — pure SVG, no external dependencies
// Each source defines one StreamPt per stage column.
// top/bot are SVG y-coordinates; the strand fills between them.
type StreamPt = { x: number; bot: number; top: number };

// Generates a closed bezier path for a single source strand.
// Uses midpoint control points for smooth organic curves.
function streamPath(pts: StreamPt[]): string {
  let d = `M ${pts[0].x} ${pts[0].top}`;
  for (let i = 1; i < pts.length; i++) {
    const cx = (pts[i - 1].x + pts[i].x) / 2;
    d += ` C ${cx} ${pts[i-1].top} ${cx} ${pts[i].top} ${pts[i].x} ${pts[i].top}`;
  }
  d += ` L ${pts[pts.length-1].x} ${pts[pts.length-1].bot}`;
  for (let i = pts.length - 2; i >= 0; i--) {
    const cx = (pts[i].x + pts[i+1].x) / 2;
    d += ` C ${cx} ${pts[i+1].bot} ${cx} ${pts[i].bot} ${pts[i].x} ${pts[i].bot}`;
  }
  return d + " Z";
}

// Render the chart
{sources.map((src) => (
  <path
    key={src.name}
    d={streamPath(src.points)}
    fill={src.color}
    fillOpacity={0.78}
  />
))}

// Crosshair + intersection dots
<line x1={crosshairX} y1={TOP} x2={crosshairX} y2={BOTTOM}
  stroke="white" strokeWidth={1} strokeOpacity={0.8} />
{sources.map((src) => {
  const pt = src.points[activeStageIndex];
  return pt.bot - pt.top >= 3 ? (
    <circle cx={crosshairX} cy={(pt.top + pt.bot) / 2}
      r={2.5} fill="white" fillOpacity={0.9} />
  ) : null;
})}

Sankey vs Alluvial

They look similar — both use curved bands between vertical bars — but they answer fundamentally different questions.

DimensionSankeyAlluvial
Primary questionWhere does the flow go?How does the distribution shift?
Time dimensionNot implied — stages are logical, not temporalColumns represent time periods
Node meaningNamed entities (sources and targets)Categories within the same classification
Link meaningQuantity of flow between two specific nodesContinuity of a category across periods
Total per columnCan vary — sources ≠ targets is validShould be equal — the same population over time
Typical useWebsite funnels, budget allocation, energy flowsCohort analysis, segment migration, election results

Tooltip patterns

Tooltips are the primary interaction surface for charts. They surface precise values that would clutter the chart if always visible.

MAY 2024Web62,400iOS41,200Total103,600Timestamp headerSeries rowsTotal row
Trigger on the nearest data point

Use a vertical cursor line that snaps to the nearest x-value as the pointer moves. Don't require hovering directly over a dot.

Show all series at that x

List every series at the hovered x-value with its colour swatch, name, and formatted value. Add a total row at the bottom when summing is meaningful.

Position intelligently

Default right of cursor. Flip left when within 140px of the right viewport edge. Never cover the data the tooltip is describing.

Format values consistently

Match the axis format: if the y-axis shows £M, the tooltip shows £42.3M — not 42300000. Use the same number of decimal places for all series.

Sankey tooltips

Show the link's flow value and percentage of source total on hover. Highlight the hovered link by increasing its opacity; dim all others to 15%.

Alluvial tooltips

Show the stage name, current period percentage, and delta from the previous period (▲ 5pp or ▼ 8pp). Highlight the full band across all columns.

Responsive behaviour

BreakpointBar / Line / AreaSankey / Alluvial
< 480pxShow last 7 data points only. Switch to a single-series sparkline if multiple series become unreadable.Collapse to a summary table or a stacked bar with percentage labels. Flow diagrams are rarely legible below 480px.
480–768pxReduce x-axis label density by 50%. Hide the legend and use direct labels on the last data point.Reduce node label font to 10px. Hide secondary labels (sub-values beneath node names). Shorten to 2 columns if 3+ columns are used.
> 768pxFull chart with all labels, legend, and interaction. Enable zoom if the time range exceeds 90 data points.Full diagram with all labels, values, and hover interactions. Use a minimum width of 560px for Sankey and 540px for Alluvial.

Burn Trajectory

A Burn Trajectory chart overlays four time series on a single axes: historical actuals, a forward forecast, a planned baseline, and a budget ceiling. It is the primary chart type for budget-vs-actual tracking in Warren's project finance views.

Line styles
SeriesStrokeDashOpacityArea fill
Historical (actual)2px solid #F59E0Bnone100%8% amber
Forecast1.5px dashed #F59E0B5, 355%3% amber
Planned baseline1px dotted gray2, 335%none
Budget ceiling1px dashed red3, 350%none
Zoom: Year2.5 px / day
Zoom: Quarter5 px / day
Zoom: Month12 px / day
Y-axis padleft 56 px
X-axis padbottom 36 px
Today line1px dashed gray 35%

Full interactive demo, scenario cards (Optimistic / Current Pace / Pessimistic), and implementation code are on the Burn Trajectory pattern page.

Accessibility

  • Wrap every SVG chart in <figure>. Add role="img" and aria-label to the <svg> element. The label should describe the key insight, not just the chart title: "Line chart showing 40% growth in weekly active users between January and June 2024."
  • Provide a data table alternative for all charts. Render it visually hidden with the sr-only utility and make it focusable from a visible 'View as table' link adjacent to the chart.
  • Never use colour as the only encoding. Sankey links and Alluvial bands must also differ by opacity, pattern, or direct label so colour-blind users can distinguish them.
  • Sankey and Alluvial diagrams are complex. Supplement them with a written summary paragraph explaining the main finding. For screen reader users, the summary is the chart.
  • Interactive charts must be keyboard navigable. Focus each data point with Tab, announce value + context with aria-live, and allow Escape to dismiss any open tooltip.
  • Touch targets on mobile chart interactions (data point dots, node rectangles in Sankey) must be at least 44×44dp. Invisible hit areas are fine — expand the touchable area beyond the visual element.