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 type | Use when | Avoid when |
|---|---|---|
| Bar / Column | Comparing discrete categories | Continuous time series |
| Line | Trends over time, continuous data | Fewer than 3 data points |
| Area | Volume over time, stacked part-to-whole | More than 3 series |
| Donut | Part-to-whole with 2–4 segments | 5+ segments or subtle differences |
| Scatter | Correlations between two continuous variables | Ordinal or categorical axes |
| Heatmap | Two-dimensional density | Precise value comparison |
| Sankey | Quantified flow through a network of stages | Categorical change over time (use Alluvial) |
| Alluvial | How a population shifts across categories over time | Non-temporal flows between named nodes |
| Source Alluvial | Multi-source conversion tracking through sequential pipeline stages | Time-series without a defined conversion endpoint |
| Treemap | Hierarchical part-to-whole at two levels | Comparing values without hierarchy |
| Funnel | Sequential drop-off through a defined process | Non-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.
1–8 words. State what the chart shows, not what to conclude. Keep it inside the chart boundary, not above it.
Include units in the axis label (£M, ms, %). Start at zero for bar charts; truncating the axis exaggerates differences.
Date labels: abbreviate to 3 chars (Jan, Feb). Rotate only as a last resort — prefer shortening labels instead.
Horizontal only. 5% white opacity in dark mode, 6% black in light mode. Never more than 5 grid lines on a chart.
Use the Sitka series palette. Direct-label bars when there are ≤5 data points; use a legend for more.
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)
Anatomy
NodeA 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.
ColumnsLeft-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.
ConservationTotal 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
- →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
- →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
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-sankeyOfficial D3 plugin. Full control over layout and rendering. Recommended when you need custom interaction or animation.
Nivo · SankeyReact wrapper with built-in tooltips, theming, and responsive container. Good for dashboards where speed matters more than customisation.
Swift ChartsNo 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
Reading an Alluvial
That stage's share of the total grew between periods. The band expands upward or downward depending on position.
That stage's share shrank. The most diagnostic case: Wishlist shrinking while Interview grows signals a healthy, advancing pipeline.
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
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
Anatomy
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.
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.
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.
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
// 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.
| Dimension | Sankey | Alluvial |
|---|---|---|
| Primary question | Where does the flow go? | How does the distribution shift? |
| Time dimension | Not implied — stages are logical, not temporal | Columns represent time periods |
| Node meaning | Named entities (sources and targets) | Categories within the same classification |
| Link meaning | Quantity of flow between two specific nodes | Continuity of a category across periods |
| Total per column | Can vary — sources ≠ targets is valid | Should be equal — the same population over time |
| Typical use | Website funnels, budget allocation, energy flows | Cohort 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.
Use a vertical cursor line that snaps to the nearest x-value as the pointer moves. Don't require hovering directly over a dot.
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.
Default right of cursor. Flip left when within 140px of the right viewport edge. Never cover the data the tooltip is describing.
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.
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%.
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
| Breakpoint | Bar / Line / Area | Sankey / Alluvial |
|---|---|---|
| < 480px | Show 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–768px | Reduce 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. |
| > 768px | Full 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.
| Series | Stroke | Dash | Opacity | Area fill |
|---|---|---|---|---|
| Historical (actual) | 2px solid #F59E0B | none | 100% | 8% amber |
| Forecast | 1.5px dashed #F59E0B | 5, 3 | 55% | 3% amber |
| Planned baseline | 1px dotted gray | 2, 3 | 35% | none |
| Budget ceiling | 1px dashed red | 3, 3 | 50% | none |
2.5 px / day5 px / day12 px / dayleft 56 pxbottom 36 px1px 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.