Sankey chart
Where a population moved between stages: flows as wide as their values, a hovered path lit.
For the same population moving between states, where it went being the reading: plans into plans and into churn, a budget into its lines. It is the wrong chart for steps one population passes through in order — that is a funnel — and for comparing sizes, which a bar chart does with less ink. Drawn on recharts' Sankey: each stage a bar as tall as what passes through it, labelled with its value before the first column and after every other, each flow a band in its source's colour at 10%. Hovering a flow or a stage lights the path through it to 25% and quiets the rest, and the kit's tooltip names the flow from stage to stage. A stage keeps the colour it names — a chart token or any CSS colour — or takes its slot by order, grey first, and each column keeps its stages in the order given: give a plan one colour and one place in both columns of a before-and-after and it reads as itself across them. It draws no cycle: recharts places a stage by walking the flow from its sources, so the link that would close a loop is left out, as are an end that names no stage, a link from a stage to itself and a value that is not a positive number. toRechartsSankey does that sorting and is exported; the root carries data-dropped with the count and the summary says it. A stage no drawn flow touches is not drawn at all — recharts finds a stage its column by walking the flows, so it would have none — and is not counted among the stages. The labels of the outer columns sit beside the plot, in a margin estimated from their text and held to 30% of the width each side; a label wider than that is clipped at the plot's edge, so keep stage names short. One image named by its label and summary, every flow in a visually hidden list beside it (sankeyRows), and recharts' keyboard layer off, so the surface is no tab stop. In a right-to-left page the flow reads from the right.
Install
npx shadcn@latest add @vibra/sankey-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { SankeyChart } from "@/components/ui/sankey-chart"
// July's plans on the left, September's on the right: a plan keeps its colour
// on both sides, and churn wears the negative tone because it means something.
const PLANS = [
{ id: "starter-jul", label: "Starter", color: "chart-1" },
{ id: "pro-jul", label: "Pro", color: "chart-2" },
{ id: "business-jul", label: "Business", color: "chart-3" },
{ id: "starter-sep", label: "Starter", color: "chart-1" },
{ id: "pro-sep", label: "Pro", color: "chart-2" },
{ id: "business-sep", label: "Business", color: "chart-3" },
{ id: "churned", label: "Churned", color: "var(--chart-negative)" },
]
const MOVES = [
{ source: "starter-jul", target: "starter-sep", value: 982 },
{ source: "starter-jul", target: "pro-sep", value: 164 },
{ source: "starter-jul", target: "churned", value: 94 },
{ source: "pro-jul", target: "pro-sep", value: 741 },
{ source: "pro-jul", target: "business-sep", value: 52 },
{ source: "pro-jul", target: "starter-sep", value: 31 },
{ source: "pro-jul", target: "churned", value: 37 },
{ source: "business-jul", target: "business-sep", value: 298 },
{ source: "business-jul", target: "pro-sep", value: 7 },
{ source: "business-jul", target: "churned", value: 6 },
]
export default function SankeyChartDemo() {
return (
<ChartCard title="Plan changes" description="Subscriptions from July to September" className="w-full">
<SankeyChart
nodes={PLANS}
links={MOVES}
label="Plan changes"
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
/>
</ChartCard>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | { id: string; label: string; color?: string }[] | — | The stages, in order; a link names a stage by its id. color is a chart token or any CSS colour, and defaults to the stage's slot by order, grey first. |
| links | { source: string; target: string; value: number }[] | — | How much moved from one stage to another. A link the chart cannot draw is left out and counted. |
| valueFormatter | (n: number) => string | thousands-separated number | Formats every number the chart prints: the stages, the tooltip, the summary and the rows. |
| height | number | 280 | Plot height in pixels. |
| nodeWidth | number | 10 | Each stage's bar, in pixels across. |
| nodePadding | number | 24 | The gap between two stages of one column, in pixels: room for the two lines of text beside each. |
| label | string | — | Names the chart ahead of its summary, e.g. "Plan changes", and names its hidden rows. |
Dependencies
Source
"use client"
import * as React from "react"
import { Sankey, type SankeyLinkProps, type SankeyNodeProps } from "recharts"
import { cn } from "@/lib/utils"
import { ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { chartDefaults, defaultValueFormatter, seriesColor } from "@/components/ui/chart-core"
import { ChartPlot, ChartRows } from "@/components/ui/chart-frame"
import {
drawnStages,
flowRows,
type IndexedLink,
LABEL_GAP,
labelRoom,
type SankeyLink,
type SankeyNode,
sankeySummary,
stageValues,
toRechartsSankey,
} from "./sankey-data"
export { sankeyRows, toRechartsSankey, type SankeyLink, type SankeyNode } from "./sankey-data"
const NODE_WIDTH = 10
// Room between two stages of one column for the two lines of text beside each.
const NODE_PADDING = 24
// The plot keeps at least this share of its width for the flows, whatever the
// labels beside the outer columns would like.
const MAX_LABEL_SHARE = 0.3
/** A flow at rest is a 10% fill; the flows on a hovered path light to 25%, and the rest step back. */
const FLOW_OPACITY = { rest: 0.1, lit: 0.25, quiet: 0.05 } as const
/** A stage off the hovered path, as a funnel step is quieted. */
const QUIET_OPACITY = 0.45
// Nothing for the config to paint: every mark takes its colour from its stage.
const CONFIG: ChartConfig = {}
// recharts' Sankey has no draw-in today, and its props do not name one; it is
// pinned off all the same (tests/motion.test.ts), so a release that adds one
// finds it off. A prop the diagram does not know is dropped before the DOM.
const NO_DRAW_IN = { isAnimationActive: false } as const
/**
* A right-to-left page reads the flow from the right: the whole drawing —
* marks, hit areas and the tooltip's place — is mirrored, and each text is
* turned back about its own box so it reads the right way round.
*/
const MIRROR = "rtl:[&_.recharts-wrapper]:-scale-x-100 rtl:[&_.recharts-tooltip-wrapper>*]:-scale-x-100"
const READ_FORWARDS = "[transform-box:fill-box] origin-center rtl:-scale-x-100"
/** What the pointer is over: a stage or a flow, by its place in the lists recharts was handed. */
type Hover = { kind: "node" | "link"; index: number }
type Lit = { stages: Set<number>; flows: Set<number> } | null
/** The path through what is under the pointer: a flow and its two stages, or a stage and every flow through it. */
function litPath(hover: Hover | null, flows: IndexedLink[]): Lit {
if (!hover) return null
if (hover.kind === "link") {
const flow = flows[hover.index]
return flow ? { stages: new Set([flow.source, flow.target]), flows: new Set([hover.index]) } : null
}
const stages = new Set([hover.index])
const through = new Set<number>()
flows.forEach((flow, i) => {
if (flow.source !== hover.index && flow.target !== hover.index) return
through.add(i)
stages.add(flow.source)
stages.add(flow.target)
})
return { stages, flows: through }
}
// Read by every mark, so lighting a path re-renders the marks and never
// asks recharts to lay the diagram out again.
const LitPath = React.createContext<Lit>(null)
type Painted = { fill?: string }
/** A stage as the diagram holds it: its paint, and its place in the node list the flows were read against. */
type Drawn = Painted & { stage: number }
function Stage({ x, y, width, height, index, payload, formatValue }: SankeyNodeProps & { formatValue: (n: number) => string }) {
const lit = React.useContext(LitPath)
const quiet = lit !== null && !lit.stages.has((payload as unknown as Drawn).stage ?? index)
// The first column writes its label before its node; every other column after.
const first = payload.sourceNodes.length === 0 && payload.targetNodes.length > 0
const textX = first ? x - LABEL_GAP : x + width + LABEL_GAP
return (
<g
data-slot="sankey-node"
data-side={first ? "start" : "end"}
data-quiet={quiet || undefined}
opacity={quiet ? QUIET_OPACITY : 1}
className="transition-opacity duration-(--duration-base) ease-(--ease-standard)"
>
<rect x={x} y={y} width={width} height={Math.max(height, 1)} rx={2} fill={(payload as Painted).fill} />
<text
x={textX}
y={y + height / 2}
textAnchor={first ? "end" : "start"}
// Anchored on the drawing's own left and right; the mirror reverses both.
direction="ltr"
fontSize={12}
fill="var(--chart-axis)"
className={READ_FORWARDS}
>
<tspan x={textX} dy="-0.2em">
{payload.name}
</tspan>
<tspan x={textX} dy="1.25em" fontWeight={500} className="tabular-nums">
{formatValue(Number(payload.value))}
</tspan>
</text>
</g>
)
}
function Flow({ sourceX, sourceY, sourceControlX, targetX, targetY, targetControlX, linkWidth, index, payload }: SankeyLinkProps) {
const lit = React.useContext(LitPath)
const strength = lit === null ? FLOW_OPACITY.rest : lit.flows.has(index) ? FLOW_OPACITY.lit : FLOW_OPACITY.quiet
return (
<path
data-slot="sankey-link"
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
fill="none"
// A flow wears the colour of the stage it leaves.
stroke={(payload.source as Painted).fill}
strokeWidth={Math.max(linkWidth, 1)}
strokeOpacity={strength}
className="transition-[stroke-opacity] duration-(--duration-base) ease-(--ease-standard)"
/>
)
}
type TooltipEntry = { payload?: { name?: string; fill?: string; source?: { name?: string; fill?: string }; target?: { name?: string } } }
/** A tooltip row: the stage, or the flow from one stage to the next, and its size through the chart's formatter. */
function tooltipRow(format: (n: number) => string) {
return function renderRow(value: unknown, _name: unknown, item: unknown) {
const mark = (item as { payload?: TooltipEntry }).payload?.payload
const flow = mark?.source && mark.target
return (
<>
<span className="size-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: flow ? mark.source?.fill : mark?.fill }} />
<div className="flex flex-1 items-center justify-between gap-3 leading-none">
<span className="text-muted-foreground">
{flow ? (
<>
{mark.source?.name}{" "}
{/* The flow's direction: it turns round with a right-to-left page, as the drawing does. */}
<span data-slot="sankey-tooltip-arrow" className="inline-block rtl:-scale-x-100">
→
</span>{" "}
{mark.target?.name}
</>
) : (
mark?.name
)}
</span>
<span className="font-medium tabular-nums">{format(Number(value))}</span>
</div>
</>
)
}
}
type DiagramProps = {
flow: ReturnType<typeof toRechartsSankey>
paints: string[]
room: { start: number; end: number }
width: number
nodeWidth: number
nodePadding: number
format: (n: number) => string
onHover: (hover: Hover | null) => void
}
/** The recharts diagram, given a frame's width so the outer labels' room can be held to a share of it. */
function SankeyDiagram({ flow, paints, room, width, nodeWidth, nodePadding, format, onHover }: DiagramProps) {
const cap = width > 0 ? Math.round(width * MAX_LABEL_SHARE) : Number.POSITIVE_INFINITY
const left = Math.min(room.start, cap)
const right = Math.min(room.end, cap)
// Both held still between renders: recharts lays the diagram out again
// whenever its data, margin or mark renderers change.
const margin = React.useMemo(() => ({ top: 12, right, bottom: 12, left }), [left, right])
const data = React.useMemo(() => {
// Only the stages a flow touches: one no flow reaches has no column to
// stand in, and with no flow at all recharts would place every stage at
// NaN. Each keeps its place in the node list, which the hover reads.
const drawn = drawnStages(flow.nodes.length, flow.links)
const at = new Map(drawn.map((stage, i) => [stage, i]))
return {
nodes: drawn.map((stage): Drawn & { name: string } => ({ ...flow.nodes[stage], fill: paints[stage], stage })),
links: flow.links.map((link) => ({ ...link, source: at.get(link.source)!, target: at.get(link.target)! })),
}
}, [flow, paints])
const node = React.useCallback((props: SankeyNodeProps) => <Stage {...props} formatValue={format} />, [format])
const link = React.useCallback((props: SankeyLinkProps) => <Flow {...props} />, [])
return (
<Sankey
{...NO_DRAW_IN}
data={data}
node={node}
link={link}
nodeWidth={nodeWidth}
nodePadding={nodePadding}
margin={margin}
// Each column keeps the stages in the order they were given, so a plan
// sits at the same height on both sides of a before-and-after.
sort={false}
// Named once, by the plot's role="img" and its summary; recharts' keyboard
// layer would add an unnamed role="application" tab stop inside it.
accessibilityLayer={false}
onMouseEnter={(item, kind) =>
onHover({ kind, index: kind === "node" ? ((item.payload as unknown as Drawn).stage ?? item.index) : item.index })
}
onMouseLeave={() => onHover(null)}
>
<ChartTooltip content={<ChartTooltipContent hideLabel formatter={tooltipRow(format)} />} />
</Sankey>
)
}
export type SankeyChartProps = Omit<React.ComponentProps<"div">, "children"> & {
nodes: SankeyNode[]
links: SankeyLink[]
/** Formats every number the chart prints: the stages, the tooltip, the summary and the rows. */
valueFormatter?: (n: number) => string
/** Plot height in pixels. */
height?: number
/** Each stage's bar, in pixels across. */
nodeWidth?: number
/** The gap between two stages of one column, in pixels. */
nodePadding?: number
/** Names the chart ahead of its summary, e.g. "Plan changes". */
label?: string
}
function SankeyChart({
className,
nodes,
links,
valueFormatter = defaultValueFormatter,
height = chartDefaults.height,
nodeWidth = NODE_WIDTH,
nodePadding = NODE_PADDING,
label,
...props
}: SankeyChartProps) {
const flow = React.useMemo(() => toRechartsSankey(nodes, links), [nodes, links])
const paints = React.useMemo(
() => nodes.map((node, i) => seriesColor({ key: node.id, label: node.label, color: node.color }, i)),
[nodes]
)
const [hover, setHover] = React.useState<Hover | null>(null)
const lit = React.useMemo(() => litPath(hover, flow.links), [hover, flow.links])
const values = stageValues(nodes.length, flow.links)
const room = labelRoom(nodes, flow.links, (i) => valueFormatter(values[i]))
const summary = sankeySummary(drawnStages(nodes.length, flow.links).length, flow.links, flow.dropped.length, valueFormatter)
return (
<div
data-slot="sankey-chart"
data-dropped={flow.dropped.length || undefined}
className={cn("w-full", className)}
{...props}
>
<LitPath.Provider value={lit}>
<ChartPlot
height={height}
config={CONFIG}
role="img"
aria-label={label ? `${label}. ${summary}` : summary}
className={cn("aspect-auto w-full", MIRROR)}
>
{(frame) => (
<SankeyDiagram
flow={flow}
paints={paints}
room={room}
width={frame.width}
nodeWidth={nodeWidth}
nodePadding={nodePadding}
format={valueFormatter}
onHover={setHover}
/>
)}
</ChartPlot>
</LitPath.Provider>
<ChartRows slot="sankey-chart-data" label={`${label ?? "Sankey chart"} data`} rows={flowRows(nodes, flow.links, valueFormatter)} />
</div>
)
}
export { SankeyChart }import { defaultValueFormatter } from "@/components/ui/chart-core"
/** A stage of the flow: the id its links name, the label it is drawn with, and an optional colour. */
export type SankeyNode = {
id: string
label: string
/** A chart token, or any CSS colour. Defaults to the chart slot by order, grey first. */
color?: string
}
/** How much moved from one stage to another. */
export type SankeyLink = { source: string; target: string; value: number }
/** A flow as recharts reads it: its two ends by their place in the node list. */
export type IndexedLink = { source: number; target: number; value: number }
/**
* The nodes and links recharts lays out — the nodes in the order given, named
* by their labels, and the links by node index — with every link that cannot
* be drawn set aside in `dropped` rather than drawn wrong: an end that names
* no node, a link from a stage to itself, a value that is not a positive
* number, and a link that would close a loop. Recharts places a stage by
* walking the flow from its sources, and a cycle is a walk that never ends;
* the link that closes one is the one left out, so the order of `links`
* decides which.
*/
export function toRechartsSankey(
nodes: SankeyNode[],
links: SankeyLink[]
): { nodes: { name: string }[]; links: IndexedLink[]; dropped: SankeyLink[] } {
// The first node to claim an id keeps it, so a repeated id cannot re-point
// the flows already read against it.
const place = new Map<string, number>()
nodes.forEach((node, i) => {
if (!place.has(node.id)) place.set(node.id, i)
})
const onward: number[][] = nodes.map(() => [])
// Whether `from` already flows on to `to` along the links kept so far,
// depth first.
const reaches = (from: number, to: number): boolean => {
const seen = new Set<number>()
const stack = [from]
while (stack.length > 0) {
const at = stack.pop()!
if (at === to) return true
if (seen.has(at)) continue
seen.add(at)
stack.push(...onward[at])
}
return false
}
const kept: IndexedLink[] = []
const dropped: SankeyLink[] = []
for (const link of links) {
const source = place.get(link.source)
const target = place.get(link.target)
const drawable =
source !== undefined &&
target !== undefined &&
source !== target &&
Number.isFinite(link.value) &&
link.value > 0 &&
!reaches(target, source)
if (!drawable) {
dropped.push(link)
continue
}
onward[source].push(target)
kept.push({ source, target, value: link.value })
}
return { nodes: nodes.map((node) => ({ name: node.label })), links: kept, dropped }
}
/** The data twin: one row per flow the chart draws, "Starter → Pro: 42". */
export function sankeyRows(
nodes: SankeyNode[],
links: SankeyLink[],
valueFormatter: (n: number) => string = defaultValueFormatter
): { label: string; readings: string }[] {
return flowRows(nodes, toRechartsSankey(nodes, links).links, valueFormatter)
}
/** {@link sankeyRows} for flows already sorted into the ones drawn. */
export function flowRows(
nodes: SankeyNode[],
kept: IndexedLink[],
valueFormatter: (n: number) => string = defaultValueFormatter
): { label: string; readings: string }[] {
return kept.map((link) => ({
label: `${nodes[link.source].label} → ${nodes[link.target].label}`,
readings: valueFormatter(link.value),
}))
}
/**
* Each stage's size, the way recharts sizes its node: the larger of what
* flows in and what flows out, so a stage that passes everything on reads
* the same on both sides.
*/
export function stageValues(stages: number, kept: IndexedLink[]): number[] {
const into = new Array<number>(stages).fill(0)
const out = new Array<number>(stages).fill(0)
for (const link of kept) {
out[link.source] += link.value
into[link.target] += link.value
}
return into.map((value, i) => Math.max(value, out[i]))
}
const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`
/** What the chart says it shows: its flows, its stages, the sum of the flows, and any it left out. */
export function sankeySummary(
stages: number,
kept: IndexedLink[],
dropped: number,
valueFormatter: (n: number) => string = defaultValueFormatter
): string {
const total = kept.reduce((sum, link) => sum + link.value, 0)
const head =
kept.length === 0
? "Sankey chart with no flows."
: `Sankey chart: ${count(kept.length, "flow")} between ${count(stages, "stage")}, ${valueFormatter(total)} in all.`
return dropped > 0 ? `${head} ${count(dropped, "flow")} left out.` : head
}
// 12px DM Sans averages about this wide a letter, and a tabular figure a
// little wider; a label's room is estimated from them because nothing can be
// measured before the plot is drawn.
const CHAR_WIDTH = 6.4
const FIGURE_WIDTH = 7.2
/** The gap between a node and the text beside it. */
export const LABEL_GAP = 8
/**
* The stages a drawn flow touches, by their place in the node list. Only
* these are drawn: recharts finds a stage its column by walking the flows, so
* a stage none of them touches has no column to stand in.
*/
export function drawnStages(stages: number, kept: IndexedLink[]): number[] {
const touched = new Set(kept.flatMap((link) => [link.source, link.target]))
return Array.from({ length: stages }, (_, i) => i).filter((i) => touched.has(i))
}
/**
* The room the outer columns' labels take: the first column writes its
* labels before its nodes, the last column after them, so each needs a
* margin as wide as its longest label or value. A stage with flows out and
* none in is in the first column, one with flows in and none out in the
* last; a stage between them writes its label over the flows, and a stage no
* flow touches is not drawn, so neither needs room.
*/
export function labelRoom(
nodes: SankeyNode[],
kept: IndexedLink[],
reading: (index: number) => string
): { start: number; end: number } {
const into = new Set(kept.map((link) => link.target))
const out = new Set(kept.map((link) => link.source))
const width = (i: number) =>
Math.ceil(Math.max(nodes[i].label.length * CHAR_WIDTH, reading(i).length * FIGURE_WIDTH)) + LABEL_GAP
let start = 0
let end = 0
nodes.forEach((_, i) => {
if (out.has(i) && !into.has(i)) start = Math.max(start, width(i))
else if (into.has(i) && !out.has(i)) end = Math.max(end, width(i))
})
return { start, end }
}import * as React from "react"
import { formatNumber } from "@/lib/format"
import type { ChartConfig } from "@/components/ui/chart"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
/** One point of a chart: the index value plus one number per series key. */
export type ChartDatum = Record<string, string | number | null | undefined>
/** One plotted measure — which key to read, what to call it, what to paint it. */
export type ChartSeries = {
key: string
label: string
/** A chart token, or any CSS colour for a brand hue. Defaults to the palette in order. */
color?: ChartToken | string
/**
* The key holding the same measure over the period before this one. Drawn as
* a dashed ghost behind the series and printed in its tooltip row as a delta.
*/
compareKey?: string
/**
* The colour the ghost is drawn in: a chart token or any CSS colour. Left
* out, the ghost wears the series' own colour; `var(--chart-neutral)` keeps
* the period before out of the palette, grey under every preset — including
* one whose first slot follows a coloured accent.
*/
compareColor?: ChartToken | string
}
/** The props every cartesian chart in the set accepts. */
export type CommonChartProps = React.ComponentProps<"div"> & {
data: ChartDatum[]
/** The key holding each point's category — the month, the day, the source. */
index: string
series: ChartSeries[]
/** Formats every number the chart prints: axis ticks, tooltips, and the text summary. */
valueFormatter?: (n: number) => string
/** Formats every index label, e.g. an ISO date into "Sep 4". */
indexFormatter?: (v: string | number) => string
/** Plot height in pixels, axis band included. */
height?: number
showLegend?: boolean
showGrid?: boolean
showXAxis?: boolean
showYAxis?: boolean
showTooltip?: boolean
/** Where the series are named: beside their last point, under the plot, or nowhere. */
legend?: ChartLegendPlacement
/** Goal lines, event markers and bands drawn over the plot and read out as text. */
annotations?: ChartAnnotation[]
/** Draws each series' `compareKey` as a dashed ghost behind it, in the series' `compareColor` if it names one, its own colour if not. */
compare?: boolean
className?: string
}
// The palette wraps rather than inventing a ninth hue: past eight series the
// colours stop being distinguishable, so fold the tail into an "Other" series.
const PALETTE_SIZE = 8
/**
* A series' colour: a chart token becomes its CSS variable, any other string is
* taken as a raw CSS colour, and a series that names none takes the next slot in
* the palette, wrapping past the eighth.
*/
export function seriesColor(series: ChartSeries, i: number): string {
const color = series.color
if (color === undefined) return `var(--chart-${(i % PALETTE_SIZE) + 1})`
return isChartToken(color) ? `var(--${color})` : color
}
/**
* The ChartConfig the chart primitive needs — it turns each entry into a
* `--color-<key>` custom property that recharts marks reference.
*/
export function buildChartConfig(series: ChartSeries[]): ChartConfig {
// fromEntries defines own properties, so a series keyed "__proto__" lands as
// data instead of reassigning the config object's prototype.
return Object.fromEntries(
series.map((entry, i) => [entry.key, { label: entry.label, color: seriesColor(entry, i) }])
)
}
/**
* The shared starting point: a 280px plot with both axes, a grid, direct labels
* at the end of each series, and a tooltip. The value axis is on — a chart
* without a printed scale is a shape, not a measurement — and the charts turn it
* off themselves when the plot is too small to carry one (`fitsYAxis`).
*/
export const chartDefaults = {
height: 280,
showLegend: true,
showGrid: true,
showXAxis: true,
showYAxis: true,
showTooltip: true,
legend: "inline-end",
} as const
/**
* A drawn line: 2px with round caps and joins reads as a stroke at any size
* and holds its own on a white sheet, where 1.5px thinned to a hair on a
* high-density screen.
*/
export const STROKE_WIDTH = 2
/**
* The gridline: the grid token, dashed, so a rule under the data reads as a
* guide rather than as a border. Every cartesian chart spreads this onto its
* CartesianGrid.
*/
export const GRID_PROPS = { stroke: "var(--chart-grid)", strokeDasharray: "3 3" } as const
/**
* The dot under the pointer: 3.5px of the series' own colour, ringed in the
* surface it sits on so it stays legible where two lines cross.
* --chart-surface falls back to the card a chart normally lives on; set it on
* any ancestor when the chart sits on another plane.
*/
export const ACTIVE_DOT = { r: 3.5, strokeWidth: 2, stroke: "var(--chart-surface, var(--card))" } as const
/**
* The corner a column turns, and the gap between two segments of one stack. A
* stack is rounded as one shape — the top corners on the topmost segment, the
* bottom corners on the lowest — so a bar reads as a printed block rather than
* a pile of separately rounded tiles.
*/
export const BAR_RADIUS = 6
export const BAR_GAP = 1
/** Axis chrome: no rule and no tick marks, so only the labels carry the scale. */
export const axisProps = { tickLine: false, axisLine: false, tickMargin: 8, fontSize: 12 } as const
/**
* The paint every axis label takes: --chart-axis, the token §3.8 names for the
* printed scale. recharts writes fill="#666" onto its own
* tick text, and the primitive's `.recharts-cartesian-axis-tick text` rule does
* not reach it — recharts 3 nests labels under
* `.recharts-cartesian-axis-tick-labels` instead — so #666 survived on the card
* at 3.16:1 in dark. Passing the fill as a tick prop puts it on the element
* itself, where nothing has to match a selector.
*/
export const AXIS_TICK = { fill: "var(--chart-axis)" } as const
/** Chart numbers fall back to thousands-separated values when no valueFormatter is given. */
export function defaultValueFormatter(value: number): string {
return formatNumber(value)
}
/** Index labels print as they arrive unless the chart is given an indexFormatter. */
export function defaultIndexFormatter(value: string | number): string {
return String(value)
}
/** What the text helpers need to turn a chart's data back into words. */
export type ChartTextOptions = {
data: ChartDatum[]
index: string
series: ChartSeries[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
function readIndex(datum: ChartDatum, index: string, format: (v: string | number) => string) {
const raw = datum[index]
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
function readValue(raw: ChartDatum[string], format: (n: number) => string) {
return typeof raw === "number" && Number.isFinite(raw) ? format(raw) : "no data"
}
function joinLabels(labels: string[]): string {
if (labels.length < 2) return labels.join("")
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}
/**
* The plotted data as text, one row per point. Every chart renders this into a
* visually hidden list, so the numbers are readable without pointing at a tooltip.
*/
export function chartRows(options: ChartTextOptions): { label: string; readings: string }[] {
const {
data,
index,
series,
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
return data.map((datum) => ({
label: readIndex(datum, index, indexFormatter),
readings: series
.map((entry) => `${entry.label} ${readValue(datum[entry.key], valueFormatter)}`)
.join(", "),
}))
}
/**
* The heading for a tooltip: the point's own index value, read straight off the
* datum. The primitive resolves its label through the config, which only works
* when the index is a string — reading the datum keeps numeric indexes intact.
*/
export function tooltipIndexLabel(
payload: ReadonlyArray<{ payload?: unknown }> | undefined,
index: string,
format: (v: string | number) => string = defaultIndexFormatter
): string {
const datum = payload?.[0]?.payload
const raw = datum && typeof datum === "object" ? (datum as ChartDatum)[index] : undefined
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
/** A one-sentence description of what a chart plots, used as its accessible name. */
export function chartSummary(kind: string, options: ChartTextOptions): string {
const { data, index, series, indexFormatter = defaultIndexFormatter } = options
if (series.length === 0) return `${kind} with no series.`
const labels = joinLabels(series.map((entry) => entry.label))
if (data.length === 0) return `${kind} of ${labels} by ${index}. No data.`
const first = readIndex(data[0], index, indexFormatter)
const last = readIndex(data[data.length - 1], index, indexFormatter)
const count = `${data.length} point${data.length === 1 ? "" : "s"}`
return `${kind} of ${labels} by ${index}, ${count} from ${first} to ${last}.`
}
/* -------------------------------------------------------------------------- */
/* Annotations */
/* -------------------------------------------------------------------------- */
/** Where a chart names its series. */
export type ChartLegendPlacement = "inline-end" | "bottom" | "none"
/** The meanings an annotation can carry; each resolves to a token, never a literal. */
export type ChartAnnotationTone = "neutral" | "brand" | "positive" | "negative" | "warning" | "info"
export type ChartAnnotation =
/** A threshold across the plot: a goal, a limit, an included allowance. */
| { kind: "line"; axis?: "x" | "y"; value: number | string; label: string; tone?: ChartAnnotationTone }
/** A moment on the index axis: a launch, a deploy, an incident. */
| { kind: "event"; x: number | string; label: string; tone?: ChartAnnotationTone; href?: string }
/**
* A stretch of the index axis: a freeze, an outage, a campaign. The label
* reads from the band's start; `align: "end"` hangs it from the band's end
* instead, for a band that runs to the edge of the plot — a 59px word over
* a 30px band at the right edge otherwise runs out of the plot.
*/
| {
kind: "band"
from: number | string
to: number | string
label: string
tone?: ChartAnnotationTone
align?: "start" | "end"
}
const ANNOTATION_PAINT: Record<ChartAnnotationTone, string> = {
neutral: "var(--faint-foreground)",
brand: "var(--brand)",
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
warning: "var(--warning)",
info: "var(--info)",
}
/**
* The paint a *meaning* takes, as opposed to a category.
*
* A series that is coded by status — 200/429/500, up/down, paid/overdue — is
* not one of eight interchangeable hues: it has to resolve to the semantic
* tokens, or a reader learns the wrong colour for "failed" on one page and
* carries it to the next. Categories keep the palette; meanings come from here.
*/
export const CHART_TONES = {
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
neutral: "var(--chart-neutral)",
warning: "var(--warning)",
info: "var(--info)",
} as const
export type ChartTone = keyof typeof CHART_TONES
export function chartTone(tone: ChartTone): string {
return CHART_TONES[tone]
}
/** The token an annotation's tone paints in. */
export function annotationPaint(tone: ChartAnnotationTone = "neutral"): string {
return ANNOTATION_PAINT[tone] ?? ANNOTATION_PAINT.neutral
}
/** The numbers an annotation pins to the value axis, so the scale can hold them. */
export function annotationValues(annotation: ChartAnnotation): number[] {
if (annotation.kind === "line" && annotation.axis !== "x" && typeof annotation.value === "number")
return [annotation.value]
return []
}
/**
* Every annotation as a line of text, appended to a chart's visually hidden
* rows: a goal line a sighted reader can see has to be readable too.
*/
export function annotationRows(
annotations: ChartAnnotation[] = [],
options: {
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
} = {}
): string[] {
const {
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
const at = (value: number | string) =>
typeof value === "number" ? valueFormatter(value) : indexFormatter(value)
return annotations.map((annotation) => {
if (annotation.kind === "line")
return `${annotation.label}: ${
annotation.axis === "x" ? indexFormatter(annotation.value) : at(annotation.value)
}`
if (annotation.kind === "event") return `${annotation.label} at ${indexFormatter(annotation.x)}`
return `${annotation.label}: ${indexFormatter(annotation.from)} to ${indexFormatter(annotation.to)}`
})
}import {
annotationValues,
type ChartAnnotation,
type ChartDatum,
type ChartSeries,
} from "@/components/ui/chart-core"
// A printed scale lands on steps a reader can add up in their head. Anything
// else — recharts' own 0/650/1.3K/2K/2.6K — makes the reader do arithmetic to
// place a point between two gridlines.
const NICE_STEPS = [1, 2, 2.5, 5, 10] as const
/** Rounds a rough interval up to the next 1, 2, 2.5 or 5 × 10ⁿ. */
export function niceStep(rough: number): number {
if (!Number.isFinite(rough) || rough <= 0) return 1
const magnitude = 10 ** Math.floor(Math.log10(rough))
const scaled = rough / magnitude
const step = NICE_STEPS.find((candidate) => scaled <= candidate * (1 + 1e-9)) ?? 10
return step * magnitude
}
// Summing a float step accumulates error, so each tick is computed from the
// index and then snapped back to the precision the step itself carries.
const snap = (value: number): number => Number(value.toPrecision(12))
/**
* Where a value axis starts. `zero` — the default everywhere — always takes
* zero in, because a count, a sum or a share is read against nothing, and a
* bar that does not start there lies about its length. `auto` fits the axis
* to the data's own range, on the same nice steps, for a level whose
* movement is the reading — a rate, a price, a temperature — and which a
* zero floor would flatten into a line along the top of the plot.
*/
export type ChartBaseline = "zero" | "auto"
/** Ticks from `bottom` to `top` on `step`, both ends included. */
function ticksBetween(bottom: number, top: number, step: number): number[] {
const ticks: number[] = []
for (let i = 0; bottom + i * step <= top + step / 2; i += 1) ticks.push(snap(bottom + i * step))
return ticks
}
/** The ticks and the domain for a range, on nice steps, both ends included. */
export function niceDomain(
min: number,
max: number,
count = 4,
options: { baseline?: ChartBaseline } = {}
): { domain: [number, number]; ticks: number[] } {
if (options.baseline === "auto") return fittedDomain(min, max, count)
const low = Math.min(0, Number.isFinite(min) ? min : 0)
const high = Math.max(Number.isFinite(max) ? max : 0, low)
// A flat series at zero still needs two gridlines, or the plot has no scale.
if (high === low) return { domain: [low, low + 1], ticks: [low, low + 1] }
const step = niceStep((high - low) / Math.max(1, count))
const bottom = Math.floor(low / step + 1e-9) * step
const top = Math.ceil(high / step - 1e-9) * step
return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}
/**
* The `auto` baseline: the data's own range, widened out to the nice steps
* either side of it, so 1.05–1.11 prints 1.04 / 1.06 / … / 1.12 rather than
* 0 / 0.5 / 1 / 1.5. A flat level still gets a scale — a step either side of
* it — and nothing to plot falls back to the zero baseline's 0 / 1.
*
* A peak drawn on the frame's edge reads as clipped, so an extreme within a
* quarter of a step of the edge gets one step more: 1.05–1.12 prints up to
* 1.14. That air never takes the axis across zero, which a level that does
* not cross it has no business reaching past.
*/
function fittedDomain(min: number, max: number, count: number): { domain: [number, number]; ticks: number[] } {
const lowest = Number.isFinite(min) ? min : 0
const highest = Number.isFinite(max) ? Math.max(max, lowest) : lowest
let low = lowest
let high = highest
if (high === low) {
if (low === 0) return niceDomain(0, 0, count)
const pad = niceStep(Math.abs(low) / 10)
low -= pad
high += pad
}
const step = niceStep((high - low) / Math.max(1, count))
let bottom = Math.floor(low / step + 1e-9) * step
let top = Math.ceil(high / step - 1e-9) * step
if (lowest - bottom < step / 4) bottom = lowest >= 0 ? Math.max(0, bottom - step) : bottom - step
if (top - highest < step / 4) top = highest <= 0 ? Math.min(0, top + step) : top + step
return { domain: [snap(bottom), snap(top)], ticks: ticksBetween(bottom, top, step) }
}
/** The printed scale from 0 to `max`: `niceTicks(2_900, 4)` is 0, 1000, 2000, 3000. */
export function niceTicks(max: number, count = 4): number[] {
return niceDomain(0, max, count).ticks
}
/**
* The lowest and highest number a set of series reaches, stacked or laid over
* one another. On the zero baseline the extent always takes zero in, so a
* hole in the data or a series of nothing reads as 0 / 0; on `auto` it is the
* data's own range, and ±Infinity when there is nothing to read.
*/
export function chartExtent(
data: ChartDatum[],
series: ChartSeries[],
options: { stacked?: boolean; compare?: boolean; baseline?: ChartBaseline } = {}
): { min: number; max: number } {
const keys = series.flatMap((entry) =>
options.compare && entry.compareKey ? [entry.key, entry.compareKey] : [entry.key]
)
const fitted = options.baseline === "auto"
let min = fitted ? Infinity : 0
let max = fitted ? -Infinity : 0
for (const datum of data) {
let stack = 0
// A row of holes is no stack at all — not a stack of zero, which a
// fitted axis would otherwise have to reach down to.
let stacked = false
for (const key of keys) {
const raw = datum[key]
if (typeof raw !== "number" || !Number.isFinite(raw)) continue
if (options.stacked) {
stack += raw
stacked = true
} else {
if (raw < min) min = raw
if (raw > max) max = raw
}
}
if (stacked) {
if (stack < min) min = stack
if (stack > max) max = stack
}
}
return { min, max }
}
/** A y axis' explicit domain and ticks for the data it has to hold. */
export function yAxisScale(
data: ChartDatum[],
series: ChartSeries[],
options: {
stacked?: boolean
compare?: boolean
count?: number
annotations?: ChartAnnotation[]
/** Where the axis starts; zero unless the chart asks to fit its data. */
baseline?: ChartBaseline
} = {}
): { domain: [number, number]; ticks: number[] } {
const { min, max } = chartExtent(data, series, options)
// A goal line above every plotted point still has to fit inside the plot.
let low = min
let high = max
for (const annotation of options.annotations ?? []) {
for (const value of annotationValues(annotation)) {
if (value < low) low = value
if (value > high) high = value
}
}
return niceDomain(low, high, options.count ?? 4, { baseline: options.baseline })
}
// Below these the axis band costs more than the scale it prints: the labels
// crowd the plot at 120px tall and eat a third of the width at 240px wide.
const MIN_AXIS_HEIGHT = 120
const MIN_AXIS_WIDTH = 240
/**
* Whether a y axis fits. Width 0 means "not measured yet" — on the server, and
* in a test with no layout — and is never taken as "too narrow".
*/
export function fitsYAxis(height: number, width = 0): boolean {
if (height < MIN_AXIS_HEIGHT) return false
return width === 0 || width >= MIN_AXIS_WIDTH
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { ChartContainer } from "@/components/ui/chart"
import { annotationRows, type ChartAnnotation } from "@/components/ui/chart-core"
import { fitsYAxis } from "@/components/ui/chart-scale"
import { ChartSkeleton } from "@/components/ui/loading-skeletons"
/* -------------------------------------------------------------------------- */
/* The frame: measure, then draw */
/* -------------------------------------------------------------------------- */
// A ChartSkeleton is a legend, a plot and a row of tick labels; the two text
// rows and their gaps take about this much, so the skeleton reserves the same
// box the finished chart will fill.
const SKELETON_CHROME = 56
/** What a chart's body is told about the box it is being drawn into. */
export type ChartFrame = {
/** The plot's own width in px, or 0 before it has been measured. */
width: number
/** False until one layout has happened — the skeleton holds the space until then. */
measured: boolean
reduced: boolean
/** Whether the value axis both was asked for and fits in the box. */
yAxis: (asked: boolean) => boolean
}
export type ChartPlotProps = Omit<React.ComponentProps<typeof ChartContainer>, "children"> & {
height: number
/** The recharts chart to draw, given what is known about the box. */
children: (frame: ChartFrame) => React.ComponentProps<typeof ChartContainer>["children"]
}
/**
* A chart's box: measured first, drawn second.
*
* Nothing can be measured on the server, so the skeleton holds the chart's
* space until the first client layout, and the ChartContainer inside then
* measures its own box and draws at that size — which is also what makes the
* mount fade a fade of the finished chart rather than of a mis-sized one. The
* width measured here is what decides whether a value axis fits, so that
* decision is handed to the chart body rather than guessed at.
*/
function ChartPlot({ height, className, style, children, ...props }: ChartPlotProps) {
const [width, setWidth] = React.useState(0)
const [measured, setMeasured] = React.useState(false)
const plot = React.useRef<HTMLDivElement | null>(null)
// Asked of the plot itself, so a frame that asked for less motion on its own
// wrapper is heard; the document root would not have carried it.
const reduced = useReducedMotion(plot)
const ref = React.useCallback((node: HTMLDivElement | null) => {
plot.current = node
if (!node) return
const read = () => setWidth(Math.round(node.getBoundingClientRect().width))
read()
// One layout has now happened, whether or not it produced a number: an
// environment with no layout engine reports 0 forever, and holding a
// skeleton there would mean no chart is ever drawn at all.
setMeasured(true)
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(read)
observer.observe(node)
return () => observer.disconnect()
}, [])
return (
<div ref={ref} data-slot="chart-plot" className="w-full">
{measured ? (
<ChartContainer
// The one piece of motion a chart has: it fades in when it first has a
// size, and never again — a re-render on new data leaves the element
// mounted, so the animation does not restart. Duration and easing are
// the theme's tokens, which are zeroed under reduced motion.
className={cn(
!reduced && "animate-in fade-in-0 duration-(--duration-base) ease-(--ease-standard)",
className
)}
style={{ height, ...style }}
{...props}
>
{children({
width,
measured,
reduced,
yAxis: (asked: boolean) => asked && fitsYAxis(height, width),
})}
</ChartContainer>
) : (
<ChartSkeleton height={Math.max(80, height - SKELETON_CHROME)} />
)}
</div>
)
}
export type ChartRowsProps = {
slot: string
label: string
rows: { label: string; readings: string }[]
annotations?: ChartAnnotation[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
/**
* A chart's numbers as text. Every plotted point, then every annotation drawn
* over them — a goal line a sighted reader can see has to be readable too.
*/
function ChartRows({
slot,
label,
rows,
annotations,
valueFormatter,
indexFormatter,
}: ChartRowsProps) {
const notes = annotationRows(annotations, { valueFormatter, indexFormatter })
return (
<ul data-slot={slot} aria-label={label} className="sr-only">
{rows.map((row, i) => (
<li key={`row-${i}`}>{`${row.label}: ${row.readings}`}</li>
))}
{notes.map((note, i) => (
<li key={`note-${i}`} data-slot="chart-annotation-row">
{note}
</li>
))}
</ul>
)
}
export { ChartPlot, ChartRows }