Sankey diagram (user flow)
type: sankey lays nodes out in columns by longest-path depth and renders flow links as bezier ribbons whose width = link value in pixels. User journey from acquisition channel through onboarding to outcome. Hover any ribbon for the source -> target value.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Charts). See the SvGrid documentation for the full API.
About this example
A Sankey diagram of a user flow in Svelte 5. type sankey takes nodes and links, lays the nodes out in columns by longest-path depth, sizes each node by the larger of its total in and out and draws each link as a bezier ribbon whose width is the link value in pixels. Hover a ribbon for the source to target value; the demo traces traffic from acquisition channels through onboarding steps to purchased, churned or pending outcomes, built from the grid's displayed rows.
type: 'sankey' takes a list of nodes and links and lays them out in columns by longest-path depth. Each node is a thin rectangle sized by max(totalIn, totalOut); each link is a bezier ribbon whose width is the link value in pixels. Hover any ribbon for the source -> target flow value.
This demo: traffic flowing from acquisition channels through onboarding steps to outcomes (purchased / churned / pending).
Imports, features and API used
Imports: @svgrid/grid
Table features registered: rowSortingFeature, columnFilteringFeature
Columns: source (From), target (To), value (Users)
SvGridApi methods called: api.getDisplayedRows()
Frequently asked questions
What shape do the rows need?
One row per link with source, target and value; the demo collects the distinct names as nodes and passes the rows as links.
How are node columns decided?
By longest path from any source: a node placed after the longest chain leading to it, so channels land on the left and outcomes on the right without manual positions.
Can links be filtered?
Yes. Filtering the grid removes links, and the diagram re-lays out from the remaining rows, dropping nodes with no flow.
Related documentation
Related articles
- Sparkline Cells in a Svelte Data Grid - Show inline trend sparklines inside grid cells using SvGrid's built-in sparkline column property - no charting library needed, just a field that holds a number array.
- Avoiding Layout Thrash in Custom Grid Cells - Layout thrash from interleaved DOM reads and writes is the most common cause of scroll jank in grids with custom cells - here is how to find it and design your way out of it.
- Building an Admin User-Management Screen in Svelte - A blueprint for a user-management grid with roles, status badges, inline edits, bulk actions, and server-side data - the patterns that keep this screen secure and usable.
Source code (165-chart-sankey.svelte)
<!-- Documented in: docs/help/charts/types.md -->
<script lang="ts">
/**
* 165. Sankey diagram (flow)
* ---------------------------
* `type: 'sankey'` takes a list of `nodes` and `links` and lays them
* out in columns by longest-path depth. Each node is a thin rectangle
* sized by max(totalIn, totalOut); each link is a bezier ribbon whose
* width is the link value in pixels. Hover any ribbon for the source
* -> target flow value.
*
* This demo: traffic flowing from acquisition channels through
* onboarding steps to outcomes (purchased / churned / pending).
*/
import {
SvGrid,
SvGridChart,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type GridColumns,
type SvGridApi,
type ChartSpec,
} from '@svgrid/grid'
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
type Row = { id: number; source: string; target: string; value: number }
const rows: Row[] = [
// Channels -> trial
{ id: 0, source: 'Organic', target: 'Trial', value: 1820 },
{ id: 1, source: 'Paid Search', target: 'Trial', value: 1240 },
{ id: 2, source: 'Social', target: 'Trial', value: 860 },
{ id: 3, source: 'Referral', target: 'Trial', value: 540 },
// Trial -> onboarding
{ id: 4, source: 'Trial', target: 'Onboarded', value: 3120 },
{ id: 5, source: 'Trial', target: 'Bounced', value: 1340 },
// Onboarded -> outcomes
{ id: 6, source: 'Onboarded', target: 'Activated', value: 1980 },
{ id: 7, source: 'Onboarded', target: 'Inactive', value: 1140 },
// Activated -> outcomes
{ id: 8, source: 'Activated', target: 'Purchased', value: 880 },
{ id: 9, source: 'Activated', target: 'Churned', value: 720 },
{ id: 10, source: 'Activated', target: 'Pending', value: 380 },
]
const columns: GridColumns<Row> = [
{ field: 'source', header: 'From', width: 150 },
{ field: 'target', header: 'To', width: 150 },
{ field: 'value', header: 'Users', width: 110, align: 'right' },
]
let api = $state<SvGridApi<typeof features, Row> | null>(null)
let displayed = $state<Row[]>(rows)
function sync() { displayed = (api?.getDisplayedRows() as Row[]) ?? rows }
const spec = $derived.by<ChartSpec>(() => {
// Build nodes from the unique source + target names of displayed links.
const ids = new Set<string>()
for (const r of displayed) { ids.add(r.source); ids.add(r.target) }
return {
type: 'sankey',
categories: [],
series: [],
sankeyNodes: [...ids].map((id) => ({ id, label: id })),
sankeyLinks: displayed.map((r) => ({ source: r.source, target: r.target, value: r.value })),
palette: ['#2563eb', '#16a34a', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9', '#14b8a6', '#f97316'],
width: 760,
height: 400,
}
})
const compact = (v: number) => (Math.abs(v) >= 1e3 ? (v / 1e3).toFixed(v % 1e3 ? 1 : 0) + 'k' : String(Math.round(v)))
/** Pane size, so the chart fills its card rather than a fixed viewBox. */
let paneW = $state(0)
let paneH = $state(0)
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-header-bg); background: var(--sg-header-bg);">
<p class="text-sm font-semibold" style="color: var(--sg-fg);">
Sankey - user flow from acquisition to outcome
</p>
<p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
Each ribbon's width is the user count. Filter the grid to drop a link and the layout
reflows. Hover any ribbon for the source -> target value.
</p>
</div>
<div class="flex flex-1 min-h-0 gap-3">
<div class="flex-1 min-w-0 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
sortable
filterable
selectionMode="none"
rowHeight={28}
containerHeight="100%"
fitColumns={true}
onApiReady={(a) => { api = a; sync() }}
onFiltersChange={sync}
onSortingChange={sync}
/>
</div>
<div class="rounded-lg border p-3" style="flex: 0 1 800px; min-width: 0; min-height: 0; border-color: var(--sg-border); background: var(--sg-bg);">
<!-- Measured box, not the card: its height comes from the parent, so the
chart cannot push the thing it is sized against. -->
<div style="width: 100%; height: 100%; min-height: 0;" bind:clientWidth={paneW} bind:clientHeight={paneH}>
{#if paneW > 40 && paneH > 40}
<SvGridChart {spec} formatValue={compact} width={paneW} height={paneH} />
{/if}
</div>
</div>
</div>
</section>More Charts examples
- Chart a selection (context menu) - With integrated charting enabled, the right-click menu gains a Chart selected range item: select a block of cells, right-click, and the chart panel opens scoped to that range - the Excel chart-this gesture, built in. The item appears only when charting is on and is appended to the default context menu automatically.
- Chart view of the grid - The `chart` prop turns the same <SvGrid> into a chart, driven by the grid’s filtered + sorted rows (search + sort flow through). A view of the grid like board and scheduler, but the renderer is free: the grid lazy-loads a built-in view wrapping the standalone SvChart via rowsToChartSpec. Flip Table <-> Chart (bar / line / area) over one source of truth.
- Candlestick / OHLC - Candlestick and OHLC price marks with an ordinal date axis. ChartSeries.ohlc carries the four prices while values keeps the closes, so the CSV export, the screen-reader table and a 10-day moving average all work with no candle-specific code. Toggle the axis: a real time axis opens a gap over every weekend, an ordinal one spaces sessions evenly and still labels them by date. The strip under the plot is the chart brush: drag its window to pan, drag an edge to resize.
- Box plot + error bars - Distribution rather than average: box plots with the 1.5 IQR whisker rule and individual outliers, next to the same data as a bar chart of the means with error bars. two regions with nearly identical means sit side by side in the bars and look nothing alike in the boxes. boxStats() summarises a raw sample, rowsToBoxSpec() does it per group, and ChartSeries.errors annotates any existing mark without changing its type.
- Axes, titles and styling - The chart hands the decisions back: a numeric x axis that spreads payload sizes the way the numbers do, pinned domains with a fixed tick interval and a formatter per axis, grid lines on or off, a shaded budget band, a vertical reference line, per-point markers and colours, a stepped dashed series on the right axis, a title / subtitle / caption, a legend on any side, and a tooltip snippet in single-series mode. Below it, 50,000 monitoring readings decimated to one point per pixel with LTTB or min / max - toggle it off to see the cost.