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.json
npx shadcn@latest add https://boardcn.dev/r/sankey-chart-card.json

npm 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.

components/charts/sankey-chart-card.tsx
"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

PropTypeDefaultDescription
linksrequiredreadonly SankeyChartLink[]
nodesrequiredreadonly SankeyChartNode[]
axisLabelsreadonly [string, string]
classNamestring
defaultRangestring
deltanumber
format(value: number) => string
headlinenumber
linkColor"source" | "target"sourceChooses which end of a ribbon supplies its colour.
onRangeChange(rangeId: string) => void
rangestringStatic period pill. `ranges` takes precedence when both are supplied.
rangesreadonly SankeyChartRange[]
titlestringTracked time