Sankey Chart
Interactive Sankey flow chart card.
Flow
A Sankey flow diagram.
Tracked time
86h
function SankeyChartDemo() {
return (
<SankeyChartCard nodes={SANKEY_NODES} links={SANKEY_LINKS} ranges={SANKEY_RANGES} />
);
}function SankeyChartDemo() {
return (
<SankeyChartCard nodes={SANKEY_NODES} links={SANKEY_LINKS} ranges={SANKEY_RANGES} />
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/sankey-chart-card.jsonnpx shadcn@latest add https://boardcn.dev/r/sankey-chart-card.jsonnpm packages
- recharts
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import { useState } from "react";
import {
ResponsiveContainer,
Sankey,
type SankeyLinkProps,
type SankeyNodeProps,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type SankeyChartNode = {
name: string;
/** CSS colour for this node. Use `neutral` for BoardCN's sink treatment. */
color?: string;
/** Optional stronger colour used while the node is hovered. */
activeColor?: string;
};
export type SankeyChartLink = {
source: string | number;
target: string | number;
value: number;
};
export type SankeyChartRange = {
id: string;
label: string;
nodes?: readonly SankeyChartNode[];
links: readonly SankeyChartLink[];
headline?: number;
/** Decimal change, for example `0.052` renders as `+5.2%`. */
delta?: number;
};
export type SankeyChartCardProps = {
title?: string;
nodes: readonly SankeyChartNode[];
links: readonly SankeyChartLink[];
headline?: number;
delta?: number;
/** Static period pill. `ranges` takes precedence when both are supplied. */
range?: string;
ranges?: readonly SankeyChartRange[];
defaultRange?: string;
onRangeChange?: (rangeId: string) => void;
format?: (value: number) => string;
axisLabels?: readonly [string, string];
/** Chooses which end of a ribbon supplies its colour. */
linkColor?: "source" | "target";
className?: string;
};
type Tone = { color: string; activeColor: string };
type HoverTarget =
| { type: "node"; index: number }
| { type: "link"; index: number };
const NEUTRAL_TONE: Tone = {
color: "light-dark(var(--color-neutral-400), var(--color-neutral-300))",
activeColor:
"light-dark(var(--color-neutral-500), var(--color-neutral-100))",
};
const PALETTE: readonly Tone[] = [2, 6, 5, 3, 8, 7, 4, 1].map(
(index) => ({
color: `var(--color-chart-${index})`,
activeColor: `var(--color-chart-${index}-active)`,
}),
);
export function formatSankeyHours(value: number): string {
return `${Math.round(value * 10) / 10}h`;
}
function resolveTone(
index: number,
color?: string,
activeColor?: string,
): Tone {
if (color) {
return {
color,
activeColor:
activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
return PALETTE[index % PALETTE.length];
}
function useAnimatedNumber(value: number): number {
const precision = Number.isInteger(value)
? 0
: Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
? 1
: 2;
const multiplier = 10 ** precision;
return useCountUp(Math.round(value * multiplier)) / multiplier;
}
function roundedNodePath(
x: number,
y: number,
width: number,
height: number,
roundLeft: boolean,
roundRight: boolean,
): string {
const radius = Math.min(5, width, height / 2);
const leftRadius = roundLeft ? radius : 0;
const rightRadius = roundRight ? radius : 0;
return [
`M${x + leftRadius},${y}`,
`H${x + width - rightRadius}`,
rightRadius
? `A${rightRadius},${rightRadius} 0 0 1 ${x + width},${y + rightRadius}`
: "",
`V${y + height - rightRadius}`,
rightRadius
? `A${rightRadius},${rightRadius} 0 0 1 ${x + width - rightRadius},${y + height}`
: "",
`H${x + leftRadius}`,
leftRadius
? `A${leftRadius},${leftRadius} 0 0 1 ${x},${y + height - leftRadius}`
: "",
`V${y + leftRadius}`,
leftRadius
? `A${leftRadius},${leftRadius} 0 0 1 ${x + leftRadius},${y}`
: "",
"Z",
].join(" ");
}
export function SankeyChartCard({
title = "Tracked time",
nodes,
links,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
format = formatSankeyHours,
axisLabels,
linkColor = "source",
className,
}: SankeyChartCardProps) {
const [hoverTarget, setHoverTarget] = useState<HoverTarget | null>(null);
const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
const availableRanges = ranges;
const selectedRange =
availableRanges?.find((item) => item.id === selectedRangeId) ??
availableRanges?.[0];
const activeRangeId = selectedRange?.id;
const activeNodes = selectedRange?.nodes ?? nodes;
const activeLinks = selectedRange?.links ?? links;
const activeHeadline = selectedRange?.headline ?? headline;
const activeDelta = selectedRange?.delta ?? delta;
const nodeIndex = (value: string | number) =>
typeof value === "number"
? value
: Math.max(
0,
activeNodes.findIndex((node) => node.name === value),
);
const tones = activeNodes.map((node, index) =>
node.color === "neutral"
? NEUTRAL_TONE
: resolveTone(index, node.color, node.activeColor),
);
const chartLinks = activeLinks.map((item) => ({
source: nodeIndex(item.source),
target: nodeIndex(item.target),
value: item.value,
}));
const outgoing = activeNodes.map((_, node) =>
chartLinks
.filter((item) => item.source === node)
.reduce((total, item) => total + item.value, 0),
);
const incoming = activeNodes.map((_, node) =>
chartLinks
.filter((item) => item.target === node)
.reduce((total, item) => total + item.value, 0),
);
const isSource = activeNodes.map((_, node) => incoming[node] === 0);
const isSink = activeNodes.map((_, node) => outgoing[node] === 0);
const nodeValues = activeNodes.map((_, node) =>
Math.max(incoming[node], outgoing[node]),
);
const sourceTotal = nodeValues.reduce(
(total, value, index) => (isSource[index] ? total + value : total),
0,
);
const sinkTotal = nodeValues.reduce(
(total, value, index) => (isSink[index] ? total + value : total),
0,
);
let displayLabel = title;
let displayValue = activeHeadline ?? sourceTotal;
if (hoverTarget?.type === "node") {
displayLabel = activeNodes[hoverTarget.index]?.name ?? title;
displayValue = nodeValues[hoverTarget.index] ?? displayValue;
} else if (hoverTarget?.type === "link") {
const item = chartLinks[hoverTarget.index];
if (item) {
displayLabel = `${activeNodes[item.source]?.name} → ${activeNodes[item.target]?.name}`;
displayValue = item.value;
}
}
const animatedValue = useAnimatedNumber(displayValue);
const isRelated = (linkIndex: number, node: number) => {
const item = chartLinks[linkIndex];
return item ? item.source === node || item.target === node : false;
};
const renderNode = ({ x, y, width, height, index }: SankeyNodeProps) => {
const tone = tones[index] ?? NEUTRAL_TONE;
const isActive =
hoverTarget?.type === "node" && hoverTarget.index === index;
let opacity = 1;
if (hoverTarget?.type === "node") {
const connected = chartLinks.some(
(item, linkIndex) =>
(item.source === hoverTarget.index ||
item.target === hoverTarget.index) &&
isRelated(linkIndex, index),
);
opacity = hoverTarget.index === index || connected ? 1 : 0.35;
} else if (hoverTarget?.type === "link") {
opacity = isRelated(hoverTarget.index, index) ? 1 : 0.35;
}
const source = isSource[index];
const sink = isSink[index];
const centerY = y + height / 2;
const sinkShare = sinkTotal > 0
? Math.round((nodeValues[index] / sinkTotal) * 100)
: 0;
return (
<g
className="transition-opacity duration-200 ease-out"
opacity={opacity}
onMouseEnter={() => setHoverTarget({ type: "node", index })}
onMouseLeave={() => setHoverTarget(null)}
>
<path
d={roundedNodePath(x, y, width, height, source, sink)}
fill={isActive ? tone.activeColor : tone.color}
className="transition-[fill] duration-150 ease-out"
/>
{source && !sink && (
<g className="pointer-events-none">
<text
x={x - 8}
y={centerY}
dy={height >= 26 ? -2 : 4}
textAnchor="end"
fontSize={13}
fontWeight={500}
fill="var(--color-text-primary)"
>
{activeNodes[index].name}
</text>
{height >= 26 && (
<text
x={x - 8}
y={centerY}
dy={14}
textAnchor="end"
fontSize={12}
fill="var(--color-text-tertiary)"
className="tabular-nums"
>
{format(nodeValues[index])}
</text>
)}
</g>
)}
{sink && (
<text
x={x + width + 8}
y={centerY}
dy={4}
textAnchor="start"
fontSize={13}
className="pointer-events-none"
>
<tspan fontWeight={500} fill="var(--color-text-primary)">
{activeNodes[index].name}
</tspan>
<tspan
fill="var(--color-text-tertiary)"
className="tabular-nums"
>
{` · ${sinkShare}%`}
</tspan>
</text>
)}
</g>
);
};
const renderLink = ({
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
}: SankeyLinkProps) => {
const item = chartLinks[index];
const toneIndex = linkColor === "target" ? item?.target : item?.source;
const tone = tones[toneIndex ?? 0] ?? NEUTRAL_TONE;
const neutralMultiplier = tone === NEUTRAL_TONE ? 1.6 : 1;
let opacity = 0.32;
if (hoverTarget?.type === "link") {
opacity = hoverTarget.index === index ? 0.7 : 0.08;
} else if (hoverTarget?.type === "node") {
opacity = isRelated(index, hoverTarget.index) ? 0.7 : 0.08;
}
return (
<path
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
fill="none"
stroke={tone.color}
strokeWidth={Math.max(1, linkWidth)}
strokeOpacity={Math.min(0.85, opacity * neutralMultiplier)}
className="transition-[stroke-opacity] duration-200 ease-out"
onMouseEnter={() => setHoverTarget({ type: "link", index })}
onMouseLeave={() => setHoverTarget(null)}
/>
);
};
return (
<section
className={cx(
"flex h-[480px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
className,
)}
>
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="w-full truncate text-body-medium text-text-secondary">
{displayLabel}
</p>
<div className="flex w-full items-center gap-2">
<p
key={`${activeRangeId ?? ""}:${hoverTarget ? `${hoverTarget.type}:${hoverTarget.index}` : "idle"}`}
className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
>
{format(animatedValue)}
</p>
{activeDelta !== undefined && (
<Chip
variant="bold"
color={
activeDelta > 0
? "lime"
: activeDelta < 0
? "rose"
: "neutral"
}
className={hoverTarget ? "invisible" : undefined}
>
{activeDelta === 0
? "0.0%"
: `${activeDelta > 0 ? "+" : "-"}${Math.round(Math.abs(activeDelta) * 1000) / 10}%`}
</Chip>
)}
</div>
</div>
{availableRanges && availableRanges.length > 0 ? (
<ChartRangeControl
ranges={availableRanges}
value={activeRangeId}
onValueChange={(id) => {
setHoverTarget(null);
setSelectedRangeId(id);
onRangeChange?.(id);
}}
/>
) : (
range && <ChartRangeControl label={range} />
)}
</div>
<div className="relative min-h-0 w-full flex-1">
<ResponsiveContainer width="100%" height="100%">
<Sankey
data={{
nodes: activeNodes.map((node) => ({ name: node.name })),
links: chartLinks,
}}
nodeWidth={12}
nodePadding={14}
linkCurvature={0.55}
iterations={32}
sort={false}
margin={{ top: 2, bottom: 2, left: 88, right: 150 }}
node={renderNode}
link={renderLink}
/>
</ResponsiveContainer>
</div>
{axisLabels && (
<div className="flex w-full justify-between pb-1 text-caption-1-medium text-text-tertiary">
<span>{axisLabels[0]}</span>
<span>{axisLabels[1]}</span>
</div>
)}
</section>
);
}"use client";
import { useState } from "react";
import {
ResponsiveContainer,
Sankey,
type SankeyLinkProps,
type SankeyNodeProps,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type SankeyChartNode = {
name: string;
/** CSS colour for this node. Use `neutral` for BoardCN's sink treatment. */
color?: string;
/** Optional stronger colour used while the node is hovered. */
activeColor?: string;
};
export type SankeyChartLink = {
source: string | number;
target: string | number;
value: number;
};
export type SankeyChartRange = {
id: string;
label: string;
nodes?: readonly SankeyChartNode[];
links: readonly SankeyChartLink[];
headline?: number;
/** Decimal change, for example `0.052` renders as `+5.2%`. */
delta?: number;
};
export type SankeyChartCardProps = {
title?: string;
nodes: readonly SankeyChartNode[];
links: readonly SankeyChartLink[];
headline?: number;
delta?: number;
/** Static period pill. `ranges` takes precedence when both are supplied. */
range?: string;
ranges?: readonly SankeyChartRange[];
defaultRange?: string;
onRangeChange?: (rangeId: string) => void;
format?: (value: number) => string;
axisLabels?: readonly [string, string];
/** Chooses which end of a ribbon supplies its colour. */
linkColor?: "source" | "target";
className?: string;
};
type Tone = { color: string; activeColor: string };
type HoverTarget =
| { type: "node"; index: number }
| { type: "link"; index: number };
const NEUTRAL_TONE: Tone = {
color: "light-dark(var(--color-neutral-400), var(--color-neutral-300))",
activeColor:
"light-dark(var(--color-neutral-500), var(--color-neutral-100))",
};
const PALETTE: readonly Tone[] = [2, 6, 5, 3, 8, 7, 4, 1].map(
(index) => ({
color: `var(--color-chart-${index})`,
activeColor: `var(--color-chart-${index}-active)`,
}),
);
export function formatSankeyHours(value: number): string {
return `${Math.round(value * 10) / 10}h`;
}
function resolveTone(
index: number,
color?: string,
activeColor?: string,
): Tone {
if (color) {
return {
color,
activeColor:
activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
return PALETTE[index % PALETTE.length];
}
function useAnimatedNumber(value: number): number {
const precision = Number.isInteger(value)
? 0
: Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
? 1
: 2;
const multiplier = 10 ** precision;
return useCountUp(Math.round(value * multiplier)) / multiplier;
}
function roundedNodePath(
x: number,
y: number,
width: number,
height: number,
roundLeft: boolean,
roundRight: boolean,
): string {
const radius = Math.min(5, width, height / 2);
const leftRadius = roundLeft ? radius : 0;
const rightRadius = roundRight ? radius : 0;
return [
`M${x + leftRadius},${y}`,
`H${x + width - rightRadius}`,
rightRadius
? `A${rightRadius},${rightRadius} 0 0 1 ${x + width},${y + rightRadius}`
: "",
`V${y + height - rightRadius}`,
rightRadius
? `A${rightRadius},${rightRadius} 0 0 1 ${x + width - rightRadius},${y + height}`
: "",
`H${x + leftRadius}`,
leftRadius
? `A${leftRadius},${leftRadius} 0 0 1 ${x},${y + height - leftRadius}`
: "",
`V${y + leftRadius}`,
leftRadius
? `A${leftRadius},${leftRadius} 0 0 1 ${x + leftRadius},${y}`
: "",
"Z",
].join(" ");
}
export function SankeyChartCard({
title = "Tracked time",
nodes,
links,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
format = formatSankeyHours,
axisLabels,
linkColor = "source",
className,
}: SankeyChartCardProps) {
const [hoverTarget, setHoverTarget] = useState<HoverTarget | null>(null);
const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
const availableRanges = ranges;
const selectedRange =
availableRanges?.find((item) => item.id === selectedRangeId) ??
availableRanges?.[0];
const activeRangeId = selectedRange?.id;
const activeNodes = selectedRange?.nodes ?? nodes;
const activeLinks = selectedRange?.links ?? links;
const activeHeadline = selectedRange?.headline ?? headline;
const activeDelta = selectedRange?.delta ?? delta;
const nodeIndex = (value: string | number) =>
typeof value === "number"
? value
: Math.max(
0,
activeNodes.findIndex((node) => node.name === value),
);
const tones = activeNodes.map((node, index) =>
node.color === "neutral"
? NEUTRAL_TONE
: resolveTone(index, node.color, node.activeColor),
);
const chartLinks = activeLinks.map((item) => ({
source: nodeIndex(item.source),
target: nodeIndex(item.target),
value: item.value,
}));
const outgoing = activeNodes.map((_, node) =>
chartLinks
.filter((item) => item.source === node)
.reduce((total, item) => total + item.value, 0),
);
const incoming = activeNodes.map((_, node) =>
chartLinks
.filter((item) => item.target === node)
.reduce((total, item) => total + item.value, 0),
);
const isSource = activeNodes.map((_, node) => incoming[node] === 0);
const isSink = activeNodes.map((_, node) => outgoing[node] === 0);
const nodeValues = activeNodes.map((_, node) =>
Math.max(incoming[node], outgoing[node]),
);
const sourceTotal = nodeValues.reduce(
(total, value, index) => (isSource[index] ? total + value : total),
0,
);
const sinkTotal = nodeValues.reduce(
(total, value, index) => (isSink[index] ? total + value : total),
0,
);
let displayLabel = title;
let displayValue = activeHeadline ?? sourceTotal;
if (hoverTarget?.type === "node") {
displayLabel = activeNodes[hoverTarget.index]?.name ?? title;
displayValue = nodeValues[hoverTarget.index] ?? displayValue;
} else if (hoverTarget?.type === "link") {
const item = chartLinks[hoverTarget.index];
if (item) {
displayLabel = `${activeNodes[item.source]?.name} → ${activeNodes[item.target]?.name}`;
displayValue = item.value;
}
}
const animatedValue = useAnimatedNumber(displayValue);
const isRelated = (linkIndex: number, node: number) => {
const item = chartLinks[linkIndex];
return item ? item.source === node || item.target === node : false;
};
const renderNode = ({ x, y, width, height, index }: SankeyNodeProps) => {
const tone = tones[index] ?? NEUTRAL_TONE;
const isActive =
hoverTarget?.type === "node" && hoverTarget.index === index;
let opacity = 1;
if (hoverTarget?.type === "node") {
const connected = chartLinks.some(
(item, linkIndex) =>
(item.source === hoverTarget.index ||
item.target === hoverTarget.index) &&
isRelated(linkIndex, index),
);
opacity = hoverTarget.index === index || connected ? 1 : 0.35;
} else if (hoverTarget?.type === "link") {
opacity = isRelated(hoverTarget.index, index) ? 1 : 0.35;
}
const source = isSource[index];
const sink = isSink[index];
const centerY = y + height / 2;
const sinkShare = sinkTotal > 0
? Math.round((nodeValues[index] / sinkTotal) * 100)
: 0;
return (
<g
className="transition-opacity duration-200 ease-out"
opacity={opacity}
onMouseEnter={() => setHoverTarget({ type: "node", index })}
onMouseLeave={() => setHoverTarget(null)}
>
<path
d={roundedNodePath(x, y, width, height, source, sink)}
fill={isActive ? tone.activeColor : tone.color}
className="transition-[fill] duration-150 ease-out"
/>
{source && !sink && (
<g className="pointer-events-none">
<text
x={x - 8}
y={centerY}
dy={height >= 26 ? -2 : 4}
textAnchor="end"
fontSize={13}
fontWeight={500}
fill="var(--color-text-primary)"
>
{activeNodes[index].name}
</text>
{height >= 26 && (
<text
x={x - 8}
y={centerY}
dy={14}
textAnchor="end"
fontSize={12}
fill="var(--color-text-tertiary)"
className="tabular-nums"
>
{format(nodeValues[index])}
</text>
)}
</g>
)}
{sink && (
<text
x={x + width + 8}
y={centerY}
dy={4}
textAnchor="start"
fontSize={13}
className="pointer-events-none"
>
<tspan fontWeight={500} fill="var(--color-text-primary)">
{activeNodes[index].name}
</tspan>
<tspan
fill="var(--color-text-tertiary)"
className="tabular-nums"
>
{` · ${sinkShare}%`}
</tspan>
</text>
)}
</g>
);
};
const renderLink = ({
sourceX,
targetX,
sourceY,
targetY,
sourceControlX,
targetControlX,
linkWidth,
index,
}: SankeyLinkProps) => {
const item = chartLinks[index];
const toneIndex = linkColor === "target" ? item?.target : item?.source;
const tone = tones[toneIndex ?? 0] ?? NEUTRAL_TONE;
const neutralMultiplier = tone === NEUTRAL_TONE ? 1.6 : 1;
let opacity = 0.32;
if (hoverTarget?.type === "link") {
opacity = hoverTarget.index === index ? 0.7 : 0.08;
} else if (hoverTarget?.type === "node") {
opacity = isRelated(index, hoverTarget.index) ? 0.7 : 0.08;
}
return (
<path
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
fill="none"
stroke={tone.color}
strokeWidth={Math.max(1, linkWidth)}
strokeOpacity={Math.min(0.85, opacity * neutralMultiplier)}
className="transition-[stroke-opacity] duration-200 ease-out"
onMouseEnter={() => setHoverTarget({ type: "link", index })}
onMouseLeave={() => setHoverTarget(null)}
/>
);
};
return (
<section
className={cx(
"flex h-[480px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
className,
)}
>
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="w-full truncate text-body-medium text-text-secondary">
{displayLabel}
</p>
<div className="flex w-full items-center gap-2">
<p
key={`${activeRangeId ?? ""}:${hoverTarget ? `${hoverTarget.type}:${hoverTarget.index}` : "idle"}`}
className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
>
{format(animatedValue)}
</p>
{activeDelta !== undefined && (
<Chip
variant="bold"
color={
activeDelta > 0
? "lime"
: activeDelta < 0
? "rose"
: "neutral"
}
className={hoverTarget ? "invisible" : undefined}
>
{activeDelta === 0
? "0.0%"
: `${activeDelta > 0 ? "+" : "-"}${Math.round(Math.abs(activeDelta) * 1000) / 10}%`}
</Chip>
)}
</div>
</div>
{availableRanges && availableRanges.length > 0 ? (
<ChartRangeControl
ranges={availableRanges}
value={activeRangeId}
onValueChange={(id) => {
setHoverTarget(null);
setSelectedRangeId(id);
onRangeChange?.(id);
}}
/>
) : (
range && <ChartRangeControl label={range} />
)}
</div>
<div className="relative min-h-0 w-full flex-1">
<ResponsiveContainer width="100%" height="100%">
<Sankey
data={{
nodes: activeNodes.map((node) => ({ name: node.name })),
links: chartLinks,
}}
nodeWidth={12}
nodePadding={14}
linkCurvature={0.55}
iterations={32}
sort={false}
margin={{ top: 2, bottom: 2, left: 88, right: 150 }}
node={renderNode}
link={renderLink}
/>
</ResponsiveContainer>
</div>
{axisLabels && (
<div className="flex w-full justify-between pb-1 text-caption-1-medium text-text-tertiary">
<span>{axisLabels[0]}</span>
<span>{axisLabels[1]}</span>
</div>
)}
</section>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
SankeyChartCard
| Prop | Type | Default | Description |
|---|---|---|---|
| linksrequired | readonly SankeyChartLink[] | — | — |
| nodesrequired | readonly SankeyChartNode[] | — | — |
| axisLabels | readonly [string, string] | — | — |
| className | string | — | — |
| defaultRange | string | — | — |
| delta | number | — | — |
| format | (value: number) => string | — | — |
| headline | number | — | — |
| linkColor | "source" | "target" | source | Chooses which end of a ribbon supplies its colour. |
| onRangeChange | (rangeId: string) => void | — | — |
| range | string | — | Static period pill. `ranges` takes precedence when both are supplied. |
| ranges | readonly SankeyChartRange[] | — | — |
| title | string | Tracked time | — |