- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Visits by channel
93k
Traffic sourceShare of visits
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>
);
} On this page