Relationship / Advanced

Sankey Diagram

Trace quantities between stages using proportional flow ribbons.

Back to collection

Interactive Chart Preview

JAVASCRIPT

A custom Chart.js plugin draws flow ribbons and nodes with native tooltips.

Live, interactive exampleChart.js

Chart.js Code Example

JAVASCRIPT
import Chart from 'chart.js/auto';

// Illustrative data used by the live preview.
const chartData = {
  "flowHeaders": [
    "Source",
    "Destination",
    "Requests"
  ],
  "flows": [
    {
      "source": "Website",
      "target": "Triage",
      "value": 60
    },
    {
      "source": "Email",
      "target": "Triage",
      "value": 40
    },
    {
      "source": "Triage",
      "target": "Self Service",
      "value": 25
    },
    {
      "source": "Triage",
      "target": "Agent",
      "value": 75
    },
    {
      "source": "Self Service",
      "target": "Resolved",
      "value": 20
    },
    {
      "source": "Self Service",
      "target": "Pending",
      "value": 5
    },
    {
      "source": "Agent",
      "target": "Resolved",
      "value": 60
    },
    {
      "source": "Agent",
      "target": "Pending",
      "value": 15
    }
  ],
  "datasets": []
};

function createConfig(chartData) {
    const flows = chartData.flows.filter(flow => flow.value > 0);
    const names = [...new Set(flows.flatMap(flow => [flow.source, flow.target]))];
    const palette = ['#2563eb', '#0f766e', '#b45309', '#7c3aed', '#be185d', '#0369a1', '#4d7c0f'];
    const nodes = names.map((name, index) => ({ name, color: palette[index % palette.length], incoming: 0, outgoing: 0, pending: 0, depth: 0, x: 0, y: 0, height: 0 }));
    const byName = new Map(nodes.map(node => [node.name, node]));
    const links = flows.map(flow => ({ source: byName.get(flow.source), target: byName.get(flow.target), value: flow.value, sy: 0, ty: 0, thickness: 0 }));
    links.forEach(link => { link.source.outgoing += link.value; link.target.incoming += link.value; link.target.pending++; });
    const queue = nodes.filter(node => node.pending === 0);
    for (let index = 0; index < queue.length; index++) {
        const node = queue[index];
        links.filter(link => link.source === node).forEach(link => {
            link.target.depth = Math.max(link.target.depth, node.depth + 1);
            if (--link.target.pending === 0)
                queue.push(link.target);
        });
    }
    const maxDepth = Math.max(...nodes.map(node => node.depth));
    const measure = chartData.flowHeaders[2];
    const columns = Array.from({ length: maxDepth + 1 }, (_, depth) => nodes.filter(node => node.depth === depth));
    const gap = Math.min(0.04, 0.5 / Math.max(...columns.map(column => column.length)));
    const scale = Math.min(...columns.map(column => (1 - gap * (column.length - 1)) / column.reduce((sum, node) => sum + Math.max(node.incoming, node.outgoing), 0)));
    const nodeWidth = Math.min(0.025, 0.4 / (maxDepth + 1));
    columns.forEach(column => {
        const occupied = column.reduce((sum, node) => sum + Math.max(node.incoming, node.outgoing) * scale, 0) + gap * (column.length - 1);
        let y = (1 - occupied) / 2;
        column.forEach(node => {
            node.x = node.depth / maxDepth * (1 - nodeWidth);
            node.y = y;
            node.height = Math.max(node.incoming, node.outgoing) * scale;
            y += node.height + gap;
        });
    });
    const outgoing = new Map(nodes.map(node => [node.name, node.y]));
    const incoming = new Map(nodes.map(node => [node.name, node.y]));
    links.forEach(link => {
        link.sy = outgoing.get(link.source.name);
        link.ty = incoming.get(link.target.name);
        link.thickness = link.value * scale;
        outgoing.set(link.source.name, link.sy + link.thickness);
        incoming.set(link.target.name, link.ty + link.thickness);
    });
    let regions = [];
    return {
        type: 'scatter',
        data: { datasets: [{ data: [
                        ...nodes.map(node => ({ x: node.x + nodeWidth / 2, y: node.y + node.height / 2 })),
                        ...links.map(link => ({ x: (link.source.x + nodeWidth + link.target.x) / 2, y: (link.sy + link.ty + link.thickness) / 2 })),
                    ], pointRadius: 0, pointHoverRadius: 0, pointHitRadius: 0 }] },
        options: {
            responsive: true, maintainAspectRatio: false, animation: false, layout: { padding: 16 },
            scales: { x: { min: 0, max: 1, display: false }, y: { min: 0, max: 1, reverse: true, display: false } },
            plugins: { legend: { display: false }, tooltip: { callbacks: {
                        title: items => { const index = items[0].dataIndex; return index < nodes.length ? nodes[index].name : `${links[index - nodes.length].source.name} → ${links[index - nodes.length].target.name}`; },
                        label: item => item.dataIndex < nodes.length ? [`Incoming ${measure}: ${nodes[item.dataIndex].incoming}`, `Outgoing ${measure}: ${nodes[item.dataIndex].outgoing}`] : `${measure}: ${links[item.dataIndex - nodes.length].value}`,
                    } } },
        },
        plugins: [{ id: 'flow-ribbons', afterDatasetsDraw(chart) {
                    const { ctx, scales } = chart, px = (value) => scales.x.getPixelForValue(value), py = (value) => scales.y.getPixelForValue(value);
                    regions = [];
                    ctx.save();
                    links.forEach((link, index) => {
                        const x0 = px(link.source.x + nodeWidth), x1 = px(link.target.x), middle = (x0 + x1) / 2;
                        const sy = py(link.sy), ty = py(link.ty), thickness = py(link.thickness) - py(0);
                        const path = new Path2D();
                        path.moveTo(x0, sy);
                        path.bezierCurveTo(middle, sy, middle, ty, x1, ty);
                        path.lineTo(x1, ty + thickness);
                        path.bezierCurveTo(middle, ty + thickness, middle, sy + thickness, x0, sy + thickness);
                        path.closePath();
                        ctx.fillStyle = link.source.color;
                        ctx.globalAlpha = 0.4;
                        ctx.fill(path);
                        regions.push({ path, index: nodes.length + index });
                    });
                    ctx.globalAlpha = 1;
                    nodes.forEach((node, index) => {
                        const x = px(node.x), y = py(node.y), width = px(node.x + nodeWidth) - x, height = py(node.y + node.height) - y;
                        const path = new Path2D();
                        path.rect(x, y, width, height);
                        ctx.fillStyle = node.color;
                        ctx.fill(path);
                        regions.push({ path, index });
                        if (height < 14)
                            return;
                        const label = node.name.length > 12 ? node.name.slice(0, 11) + '…' : node.name;
                        ctx.font = '11px sans-serif';
                        const textWidth = ctx.measureText(label).width;
                        const left = node.depth === maxDepth ? x - textWidth - 5 : x + width + 5;
                        ctx.fillStyle = '#fff';
                        ctx.fillRect(left - 2, y + height / 2 - 8, textWidth + 4, 16);
                        ctx.fillStyle = '#1e293b';
                        ctx.textAlign = 'left';
                        ctx.textBaseline = 'middle';
                        ctx.fillText(label, left, y + height / 2);
                    });
                    ctx.restore();
                }, afterEvent(chart, args) {
                    const { x, y, type } = args.event;
                    const region = x === null || y === null || type === 'mouseout' ? undefined : [...regions].reverse().find(region => chart.ctx.isPointInPath(region.path, x, y));
                    const active = region ? [{ datasetIndex: 0, index: region.index }] : [];
                    chart.setActiveElements(active);
                    chart.tooltip?.setActiveElements(active, { x: x ?? 0, y: y ?? 0 });
                    args.changed = true;
                } }],
    };
}

const chart = new Chart(document.getElementById('myChart'), createConfig(chartData));

Preview and code follow the same library selection.

Run This Example

Install the package below in a browser project such as Vite. Put the copied code in a client JavaScript module and add this container to the page before the module runs.

npm install chart.js@4.5.1
<div style="height:320px"><canvas id="myChart"></canvas></div>

The example includes its sample data. When removing the chart, destroy or dispose of its instance and disconnect any resize observer.

Reading This Example

This illustrative process starts with 100 support requests: 60 from Website and 40 from Email. Triage sends 25 to Self Service and 75 to Agent. Self Service resolves 20 and leaves 5 pending; Agent resolves 60 and leaves 15 pending. The outcomes are 80 resolved and 20 pending. All intermediate nodes balance, and the 300-request sum across links counts the cohort at three transitions.

All library tabs use these illustrative values. Rendering styles and library APIs differ, so use the table to compare exact numbers.

About this example & sample data
Sample values used in the chart
SourceDestinationRequests
WebsiteTriage60
EmailTriage40
TriageSelf Service25
TriageAgent75
Self ServiceResolved20
Self ServicePending5
AgentResolved60
AgentPending15

READ THE CHART

Trace Quantities Along Their Routes

A Sankey diagram connects stages with ribbons whose thickness represents a quantity. It helps you see where a flow splits, joins, or reaches an outcome. Read the source and destination of each ribbon before comparing its width with another.

01

Give Each Node a Consistent Name

Each table row defines a source, destination, and nonnegative value. Names identify nodes exactly after trimming spaces, including capitalization. Reuse a name to connect links through the same node. Enter only one row per directed pair. Self-links and cycles are invalid in this example; use stage-specific names for a process that returns to the same activity later.

02

Read Ribbon Thickness, Not Length

Ribbon thickness at its endpoints represents the supplied amount on a shared scale. Horizontal distance and curve length come from the layout, so they do not measure duration or effort. Native libraries arrange nodes differently while preserving the flow values. Color identifies a source node, not an additional measurement.

03

Inspect Incoming and Outgoing Totals

A node is tall enough for the larger of its incoming and outgoing totals. Tooltips state both amounts. Edited flows do not have to balance, and the chart does not invent a balancing link. If the totals differ, check the period, units, scope, and missing routes before interpreting the gap as a gain or loss.

04

Avoid Counting the Same Flow Twice

The same item can cross several ribbons, so their sum may count it several times. Choose a meaningful entry or exit boundary when calculating a total. Zero links remain in the table and copied examples but have no visible ribbon; at least one flow must be positive. Many nodes or stages can crowd labels, so use the table for exact values or simplify the displayed process.

Edit Chart Data