For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Sankey Chart Card 01

A two-column flow chart with selectable periods.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/sankey-chart-card-01

Pass ordered nodes and links containing source and target names and positive values. Optional ranges supply alternate links for the period menu. format customizes values, and axisLabels names the two sides. Pass a scoped theme class through popupClassName when the menu is rendered outside the preview scope. Hover or focus a node to highlight its connections.

Source

"use client";

import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
} from "@/components/ui/empty";
import { Reveal } from "@/components/ui/reveal";

export interface SankeyNode {
  name: string;
  color?: string;
}
export interface SankeyLink {
  source: string;
  target: string;
  value: number;
}
export interface SankeyRange {
  id: string;
  label: string;
  links: SankeyLink[];
}

const colors = [
  "var(--chart-1)",
  "var(--chart-5)",
  "var(--chart-2)",
  "var(--chart-4)",
  "var(--chart-3)",
];

/** A two-column flow chart. Nodes are ordered as supplied in each column. */
export function SankeyChartCard({
  title = "Activity breakdown",
  nodes,
  links,
  ranges,
  format = (value) => value.toLocaleString(),
  axisLabels = ["Origin", "Share of total"],
  popupClassName,
  className,
}: {
  title?: string;
  nodes: SankeyNode[];
  links: SankeyLink[];
  ranges?: SankeyRange[];
  format?: (value: number) => string;
  axisLabels?: [string, string];
  popupClassName?: string;
  className?: string;
}) {
  const [rangeId, setRangeId] = React.useState(ranges?.[0]?.id);
  const [active, setActive] = React.useState<string | null>(null);
  const range = ranges?.find((item) => item.id === rangeId) ?? ranges?.[0];
  const names = new Set(nodes.map((node) => node.name));
  const flows = (range?.links ?? links).filter(
    (link) =>
      names.has(link.source) &&
      names.has(link.target) &&
      link.source !== link.target &&
      Number.isFinite(link.value) &&
      link.value > 0
  );
  const sources = nodes.filter((node) =>
    flows.some((link) => link.source === node.name)
  );
  const targets = nodes.filter((node) =>
    flows.some((link) => link.target === node.name)
  );
  const total = flows.reduce((sum, link) => sum + link.value, 0);
  const nodeValue = (name: string, side: "source" | "target") =>
    flows.reduce(
      (sum, link) => sum + (link[side] === name ? link.value : 0),
      0
    );
  const height = 300;
  const gap = 12;
  const maxCount = Math.max(sources.length, targets.length, 1);
  const scale = total ? Math.max(0, height - (maxCount - 1) * gap) / total : 0;
  const positions = (list: SankeyNode[], side: "source" | "target") => {
    let y = (height - (total * scale + Math.max(0, list.length - 1) * gap)) / 2;
    return list.map((node) => {
      const value = nodeValue(node.name, side);
      const result = { ...node, y, height: value * scale, value };
      y += result.height + gap;
      return result;
    });
  };
  const left = positions(sources, "source");
  const right = positions(targets, "target");
  const offsetsLeft = new Map<string, number>();
  const offsetsRight = new Map<string, number>();
  const ribbons = flows.flatMap((link, index) => {
    const source = left.find((node) => node.name === link.source);
    const target = right.find((node) => node.name === link.target);
    if (!source || !target) {
      return [];
    }
    const width = link.value * scale;
    const y1 = source.y + (offsetsLeft.get(link.source) ?? 0);
    const y2 = target.y + (offsetsRight.get(link.target) ?? 0);
    offsetsLeft.set(link.source, y1 - source.y + width);
    offsetsRight.set(link.target, y2 - target.y + width);
    return [
      {
        ...link,
        index,
        y1,
        y2,
        width,
        color: source.color ?? colors[left.indexOf(source) % colors.length],
      },
    ];
  });
  const selectedLink = ribbons.find((link) => active === `link-${link.index}`);
  const selectedNode = [...left, ...right].find(
    (node) => active === `node-${node.name}`
  );
  const headline = selectedLink?.value ?? selectedNode?.value ?? total;
  const headlineRef = useCountUp<HTMLParagraphElement>(headline, { format });
  const highlighted = (source: string, target: string, index: number) =>
    !active ||
    active === `link-${index}` ||
    active === `node-${source}` ||
    active === `node-${target}`;

  return (
    <Reveal variant="card" className="min-w-0">
      <section
        className={cn(
          "flex min-w-0 flex-col gap-4 rounded-[16px] bg-(--surface-subtle,var(--muted)) p-4",
          className
        )}
      >
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <p className="text-sm leading-5 font-medium text-muted-foreground">
              {selectedLink
                ? `${selectedLink.source} → ${selectedLink.target}`
                : (selectedNode?.name ?? title)}
            </p>
            <p
              ref={headlineRef}
              className="text-2xl leading-8.5 font-medium text-foreground tabular-nums"
            >
              {format(headline)}
            </p>
          </div>
          {range && (
            <DropdownMenu>
              <DropdownMenuTrigger
                render={<Button variant="outline" size="sm" />}
              >
                <RiCalendarLine data-icon="inline-start" />
                {range.label}
                <RiArrowDownSLine
                  data-icon="inline-end"
                  className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
                />
              </DropdownMenuTrigger>
              <DropdownMenuContent
                align="end"
                className={cn("w-44", popupClassName)}
              >
                <DropdownMenuGroup>
                  {ranges?.map((item) => (
                    <DropdownMenuItem
                      key={item.id}
                      onClick={() => {
                        setRangeId(item.id);
                        setActive(null);
                      }}
                    >
                      {item.label}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          )}
        </div>
        {total > 0 ? (
          <svg
            viewBox="0 0 700 300"
            className="h-auto w-full overflow-visible"
            role="img"
            aria-label={`${title}: ${format(total)}`}
            onMouseLeave={() => setActive(null)}
          >
            {ribbons.map((link) => (
              <path
                key={link.index}
                d={`M 104 ${link.y1} C 260 ${link.y1}, 360 ${link.y2}, 516 ${link.y2} L 516 ${link.y2 + link.width} C 360 ${link.y2 + link.width}, 260 ${link.y1 + link.width}, 104 ${link.y1 + link.width} Z`}
                fill={link.color}
                fillOpacity={
                  highlighted(link.source, link.target, link.index)
                    ? 0.35
                    : 0.08
                }
                className="sankey-chart-card-01-fade transition-all duration-150 motion-reduce:transition-none"
                onMouseEnter={() => setActive(`link-${link.index}`)}
              />
            ))}
            {left.map((node, index) => (
              <g
                key={node.name}
                tabIndex={0}
                role="img"
                aria-label={`${node.name}: ${format(node.value)}`}
                onFocus={() => setActive(`node-${node.name}`)}
                onBlur={() => setActive(null)}
                onMouseEnter={() => setActive(`node-${node.name}`)}
                className="sankey-chart-card-01-fade group outline-none"
                onMouseDown={(event) => event.preventDefault()}
              >
                <rect
                  x="94"
                  y={node.y}
                  width="10"
                  height={node.height}
                  rx="4"
                  fill={node.color ?? colors[index % colors.length]}
                  className="group-focus-visible:stroke-ring group-focus-visible:stroke-2"
                />
                <text
                  x="86"
                  y={node.y + node.height / 2 - 2}
                  textAnchor="end"
                  className="fill-foreground text-sm font-medium"
                >
                  {node.name}
                </text>
                <text
                  x="86"
                  y={node.y + node.height / 2 + 12}
                  textAnchor="end"
                  className="fill-muted-foreground"
                  fontSize={13}
                >
                  {format(node.value)}
                </text>
              </g>
            ))}
            {right.map((node) => (
              <g
                key={node.name}
                tabIndex={0}
                role="img"
                aria-label={`${node.name}: ${Math.round((node.value / total) * 100)}%`}
                onFocus={() => setActive(`node-${node.name}`)}
                onBlur={() => setActive(null)}
                onMouseEnter={() => setActive(`node-${node.name}`)}
                className="sankey-chart-card-01-fade group outline-none"
                onMouseDown={(event) => event.preventDefault()}
              >
                <rect
                  x="516"
                  y={node.y}
                  width="10"
                  height={node.height}
                  rx="4"
                  className="fill-muted-foreground group-focus-visible:stroke-ring group-focus-visible:stroke-2"
                />
                <text
                  x="534"
                  y={node.y + node.height / 2 + 4}
                  className="fill-foreground text-sm font-medium"
                >
                  {node.name}
                  <tspan className="fill-muted-foreground font-normal">
                    {" "}
                    · {Math.round((node.value / total) * 100)}%
                  </tspan>
                </text>
              </g>
            ))}
          </svg>
        ) : (
          <Empty className="min-h-48">
            <EmptyHeader>
              <EmptyDescription>No flow data available.</EmptyDescription>
            </EmptyHeader>
          </Empty>
        )}
        <div className="flex justify-between gap-4 text-xs text-muted-foreground">
          <span>{axisLabels[0]}</span>
          <span>{axisLabels[1]}</span>
        </div>
      </section>
    </Reveal>
  );
}