Charts
Token-themed data visualisations — the core multi-series chart plus dependency-free SVG variants for sankey, word-cloud, venn and violin plots.
Preview
A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.
<div class="space-y-4">
<x-ui.chart-sankey :height="320" label="{{ __('Acquisition funnel') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.') }}
</p>
</div>
Installation
php artisan ui:add chart-sankey
Note
This component ships an Alpine behavior module at
resources/js/ui/chart-sankey.js. Import it once from your bundle so it registers on alpine:init:
import './chart-sankey.js';
Registry contract
php artisan ui:add chart-sankey
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/chart-sankey.blade.php -
resources/js/ui/chart-sankey.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Sankey Chart (`chart-sankey`)
A dependency-free, token-themed SVG Sankey diagram that visualises weighted flow between staged nodes, with hover tooltips on flows and nodes.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add chart-sankey
```
## Usage
```blade
<div class="space-y-4">
<x-ui.chart-sankey :height="320" label="{{ __('Acquisition funnel') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.') }}
</p>
</div>
```
## Props
- `nodes` (mixed|null, default `null`) — Stage nodes: each an object with id, label and a 0-based column index. Defaults to a demo acquisition funnel.
- `links` (mixed|null, default `null`) — Flows between nodes: each an object with source, target and a numeric value.
- `height` (int, default `340`) — SVG height in pixels; width fills the container.
- `label` (string, default `Sankey flow diagram`) — Accessible name for the figure.
- `showValues` (bool, default `true`) — Shows each node's total next to its label.
- `linkColor` (string, default `source`) — Ribbon colouring: source (one solid colour per origin), gradient (blends source into target), or muted (a single neutral so the nodes carry the hue).
- `unit` (string, default ``) — Suffix appended to node totals and tooltip values, such as "k" or " users".
## Use when
- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Visualising weighted flow between staged categories, such as an acquisition funnel or a budget breakdown across stages.
- Needing to show how a total splits and re-combines across two or more sequential columns.
## Avoid when
- Do not use a chart when users mainly need exact lookup or when a single metric would be clearer as text or a stat.
- The flow is a simple linear funnel with no branching between stages; a bar chart reads it more simply.
- The relationships are a general graph with no staged columns; use network-graph instead.
## Anti-patterns
- Decorative charts without a meaningful question
## Rules
- Use the `<brok:chart-sankey>` tag (or `<x-ui.chart-sankey>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/chart-sankey
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chart-sankey.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- A staged acquisition funnel framed by chart-card; the period badge is
neutral because it is a label, not a delta. --}}
<x-ui.chart-card heading="Acquisition funnel" subheading="How visitors flow from source to outcome" trend="Last 30 days" trend-variant="neutral">
<x-ui.chart-sankey
:height="320"
:nodes="[
['id' => 'organic', 'label' => __('Organic'), 'column' => 0],
['id' => 'paid', 'label' => __('Paid'), 'column' => 0],
['id' => 'referral', 'label' => __('Referral'), 'column' => 0],
['id' => 'signups', 'label' => __('Signups'), 'column' => 1],
['id' => 'bounced', 'label' => __('Bounced'), 'column' => 1],
['id' => 'activated', 'label' => __('Activated'), 'column' => 2],
['id' => 'churned', 'label' => __('Churned'), 'column' => 2],
]"
:links="[
['source' => 'organic', 'target' => 'signups', 'value' => 35],
['source' => 'organic', 'target' => 'bounced', 'value' => 15],
['source' => 'paid', 'target' => 'signups', 'value' => 18],
['source' => 'paid', 'target' => 'bounced', 'value' => 12],
['source' => 'referral', 'target' => 'signups', 'value' => 15],
['source' => 'referral', 'target' => 'bounced', 'value' => 5],
['source' => 'signups', 'target' => 'activated', 'value' => 50],
['source' => 'signups', 'target' => 'churned', 'value' => 18],
['source' => 'bounced', 'target' => 'churned', 'value' => 32],
]"
label="{{ __('Acquisition funnel from source to outcome') }}"
/>
</x-ui.chart-card>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | mixed | null | null | Stage nodes: each an object with id, label and a 0-based column index. Defaults to a demo acquisition funnel. |
| links | mixed | null | null | Flows between nodes: each an object with source, target and a numeric value. |
| height | int | 340 | SVG height in pixels; width fills the container. |
| label | string | Sankey flow diagram | Accessible name for the figure. |
| showValues | bool | true | Shows each node's total next to its label. |
| linkColor | string | source | Ribbon colouring: source (one solid colour per origin), gradient (blends source into target), or muted (a single neutral so the nodes carry the hue). |
| unit | string | Suffix appended to node totals and tooltip values, such as "k" or " users". |
Slots
default— Unused; the diagram renders from the nodes and links props only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders without arguments using built-in demo data, so it previews immediately.
- Hovering a node or a link ribbon shows a chart-tooltip with that item's label and value.
- Dependency-free SVG: no chart library, computed and laid out entirely in Blade.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Reveal a visual trend, distribution, flow, or relationship.
Use when
- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Visualising weighted flow between staged categories, such as an acquisition funnel or a budget breakdown across stages.
- Needing to show how a total splits and re-combines across two or more sequential columns.
Avoid when
- Do not use a chart when users mainly need exact lookup or when a single metric would be clearer as text or a stat.
- The flow is a simple linear funnel with no branching between stages; a bar chart reads it more simply.
- The relationships are a general graph with no staged columns; use network-graph instead.
Use instead
- Table for exact lookup
- Stat for a single value
Anti-patterns
- Decorative charts without a meaningful question
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
managed
- The figure carries role="img" with an aria-label from label, so a screen reader hears a named summary instead of parsing raw SVG.
- Node totals render as visible text next to each label, so the flow's numbers are not locked inside hover-only tooltips.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="chart-sankey-{{ $record->id }}">
<div class="space-y-4">
<x-ui.chart-sankey :height="320" label="{{ __('Acquisition funnel') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.') }}
</p>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Nodes: each ['id' => string, 'label' => string, 'column' => int (0-based stage)].
'nodes' => null,
// Links: each ['source' => nodeId, 'target' => nodeId, 'value' => number].
'links' => null,
'height' => 340,
'label' => 'Sankey flow diagram',
// Show each node's total next to its label.
'showValues' => true,
// Ribbon colouring: `source` (one solid colour per origin), `gradient`
// (blends source → target) or `muted` (a single neutral so the nodes carry
// the hue).
'linkColor' => 'source',
// Optional unit appended to node totals + tooltip values (e.g. "k", " users").
'unit' => '',
])
@php
// ── Demo data (a marketing acquisition funnel) so it renders without args ──
$nodes ??= [
['id' => 'organic', 'label' => __('Organic'), 'column' => 0],
['id' => 'paid', 'label' => __('Paid'), 'column' => 0],
['id' => 'referral', 'label' => __('Referral'), 'column' => 0],
['id' => 'signups', 'label' => __('Signups'), 'column' => 1],
['id' => 'bounced', 'label' => __('Bounced'), 'column' => 1],
['id' => 'activated', 'label' => __('Activated'), 'column' => 2],
['id' => 'churned', 'label' => __('Churned'), 'column' => 2],
];
$links ??= [
['source' => 'organic', 'target' => 'signups', 'value' => 35],
['source' => 'organic', 'target' => 'bounced', 'value' => 15],
['source' => 'paid', 'target' => 'signups', 'value' => 18],
['source' => 'paid', 'target' => 'bounced', 'value' => 12],
['source' => 'referral', 'target' => 'signups', 'value' => 15],
['source' => 'referral', 'target' => 'bounced', 'value' => 5],
['source' => 'signups', 'target' => 'activated', 'value' => 50],
['source' => 'signups', 'target' => 'churned', 'value' => 18],
['source' => 'bounced', 'target' => 'churned', 'value' => 32],
];
$showValues = filter_var($showValues, FILTER_VALIDATE_BOOLEAN);
$linkColor = in_array($linkColor, ['source', 'gradient', 'muted'], true) ? $linkColor : 'source';
// ── Geometry ───────────────────────────────────────────────────────────
$W = 760;
$H = max(160, (int) $height);
$nodeW = 12;
$labelW = 96; // room reserved outside the first/last column for labels
$top = 12;
$bottom = $H - 12;
// The neutral chart palette cycles per node; `text-chart-N` utilities read
// the hue-spaced `--chart-1..5` tokens (light + dark), so five sources never
// collide with a status colour.
$palette = ['text-chart-1', 'text-chart-2', 'text-chart-3', 'text-chart-4', 'text-chart-5'];
$colOf = $colorOf = $labelOf = [];
foreach (array_values($nodes) as $i => $n) {
$colOf[$n['id']] = (int) $n['column'];
$colorOf[$n['id']] = $palette[$i % count($palette)];
$labelOf[$n['id']] = $n['label'];
}
// Incoming / outgoing totals per node; a node's weight is the larger side.
$inSum = $outSum = [];
foreach ($links as $l) {
$outSum[$l['source']] = ($outSum[$l['source']] ?? 0) + $l['value'];
$inSum[$l['target']] = ($inSum[$l['target']] ?? 0) + $l['value'];
}
$weight = [];
foreach ($nodes as $n) {
$weight[$n['id']] = max($inSum[$n['id']] ?? 0, $outSum[$n['id']] ?? 0);
}
$columns = [];
foreach ($nodes as $n) {
$columns[(int) $n['column']][] = $n['id'];
}
ksort($columns);
$maxCol = max(array_keys($columns));
// Column x positions leave label gutters on both edges.
$plotX0 = $labelW;
$plotX1 = $W - $labelW - $nodeW;
$colX = [];
foreach (array_keys($columns) as $c) {
$colX[$c] = $maxCol > 0 ? $plotX0 + ($c / $maxCol) * ($plotX1 - $plotX0) : $plotX0;
}
// One vertical scale for every column (px per unit), fixed by the heaviest
// column, so a stage that loses flow visibly shrinks instead of being
// stretched to full height. The gap between stacked nodes is 8% of the
// plot, split evenly.
$vGap = max(8, round(($bottom - $top) * 0.08));
$scale = INF;
foreach ($columns as $ids) {
$total = array_sum(array_map(fn ($id) => $weight[$id], $ids)) ?: 1;
$avail = ($bottom - $top) - (count($ids) - 1) * $vGap;
$scale = min($scale, $avail / $total);
}
$geom = []; // id => ['y' => , 'h' => ]
foreach ($columns as $c => $ids) {
$total = array_sum(array_map(fn ($id) => $weight[$id], $ids));
$used = $total * $scale + (count($ids) - 1) * $vGap;
$y = $top + (($bottom - $top) - $used) / 2; // centre the column vertically
foreach ($ids as $id) {
$h = max(3, $weight[$id] * $scale);
$geom[$id] = ['y' => $y, 'h' => $h];
$y += $h + $vGap;
}
}
// Order each node's outgoing bands by the target's vertical position (and
// incoming bands by the source's) so ribbons leave and arrive in the same
// order they are stacked — fewer crossings, no ribbon folding over itself.
$ordered = array_values($links);
usort($ordered, fn ($a, $b) => ($geom[$a['source']]['y'] <=> $geom[$b['source']]['y']) ?: ($geom[$a['target']]['y'] <=> $geom[$b['target']]['y']));
$outOff = $inOff = [];
$outOrder = $ordered;
usort($outOrder, fn ($a, $b) => $geom[$a['target']]['y'] <=> $geom[$b['target']]['y']);
$inOrder = $ordered;
usort($inOrder, fn ($a, $b) => $geom[$a['source']]['y'] <=> $geom[$b['source']]['y']);
$bandStart = []; // link key => [sy, ty]
$key = fn ($l) => $l['source'].'→'.$l['target'];
foreach ($outOrder as $l) {
$s = $l['source'];
$bandStart[$key($l)]['sy'] = $geom[$s]['y'] + ($outOff[$s] ?? 0);
$outOff[$s] = ($outOff[$s] ?? 0) + ($l['value'] / ($outSum[$s] ?: 1)) * $geom[$s]['h'];
}
foreach ($inOrder as $l) {
$t = $l['target'];
$bandStart[$key($l)]['ty'] = $geom[$t]['y'] + ($inOff[$t] ?? 0);
$inOff[$t] = ($inOff[$t] ?? 0) + ($l['value'] / ($inSum[$t] ?: 1)) * $geom[$t]['h'];
}
$uid = substr(md5($label.json_encode($nodes)), 0, 6);
$ribbons = [];
foreach ($ordered as $i => $l) {
$s = $l['source'];
$t = $l['target'];
$sBand = ($l['value'] / ($outSum[$s] ?: 1)) * $geom[$s]['h'];
$tBand = ($l['value'] / ($inSum[$t] ?: 1)) * $geom[$t]['h'];
$sy = $bandStart[$key($l)]['sy'];
$ty = $bandStart[$key($l)]['ty'];
$x0 = $colX[$colOf[$s]] + $nodeW;
$x1 = $colX[$colOf[$t]];
$mx = ($x0 + $x1) / 2;
$ribbons[] = [
'id' => "sankey-{$uid}-{$i}",
'source' => $s,
'target' => $t,
'from' => $labelOf[$s],
'to' => $labelOf[$t],
'value' => $l['value'],
'x0' => $x0,
'x1' => $x1,
'd' => sprintf(
'M%.2f %.2f C%.2f %.2f %.2f %.2f %.2f %.2f L%.2f %.2f C%.2f %.2f %.2f %.2f %.2f %.2f Z',
$x0, $sy, $mx, $sy, $mx, $ty, $x1, $ty,
$x1, $ty + $tBand, $mx, $ty + $tBand, $mx, $sy + $sBand, $x0, $sy + $sBand,
),
];
}
$format = fn ($v) => (is_float($v) && floor($v) !== $v ? number_format($v, 1) : number_format((float) $v)).$unit;
@endphp
<div
x-data="uiChartSankey()"
data-slot="chart-sankey"
data-link-color="{{ $linkColor }}"
role="img"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => 'relative w-full text-foreground']) }}
>
{{-- Pointer tooltip; reads the data-* attributes the ribbons/nodes carry. --}}
<x-ui.chart-tooltip
x-show="open"
x-cloak
class="absolute -translate-x-1/2 -translate-y-full -mt-2 whitespace-nowrap"
::style="`inset-inline-start:${x}px; top:${y}px`"
>
<div data-slot="chart-tooltip-row" class="flex items-center justify-between gap-4">
<span class="text-muted-foreground" x-text="label"></span>
<span class="font-medium tabular-nums" x-text="value"></span>
</div>
</x-ui.chart-tooltip>
<svg
viewBox="0 0 {{ $W }} {{ $H }}"
width="100%"
height="{{ $H }}"
preserveAspectRatio="xMidYMid meet"
class="h-auto w-full"
fill="none"
aria-hidden="true"
>
@if ($linkColor === 'gradient')
<defs>
@foreach ($ribbons as $ribbon)
{{-- Each stop takes its colour from a token utility on the stop
itself (currentColor), so the gradient stays theme-bound. --}}
<linearGradient id="{{ $ribbon['id'] }}" gradientUnits="userSpaceOnUse" x1="{{ round($ribbon['x0'], 2) }}" x2="{{ round($ribbon['x1'], 2) }}" y1="0" y2="0">
<stop offset="0" class="{{ $colorOf[$ribbon['source']] }}" stop-color="currentColor" />
<stop offset="1" class="{{ $colorOf[$ribbon['target']] }}" stop-color="currentColor" />
</linearGradient>
@endforeach
</defs>
@endif
{{-- Flow ribbons (behind the nodes). Hovering one lifts it and fades the
rest; hovering a node lifts every ribbon touching it (ui.css). --}}
<g data-slot="chart-sankey-links">
@foreach ($ribbons as $ribbon)
<path
data-slot="chart-sankey-link"
data-source="{{ $ribbon['source'] }}"
data-target="{{ $ribbon['target'] }}"
@class([
$colorOf[$ribbon['source']] => $linkColor === 'source',
'text-muted-foreground' => $linkColor === 'muted',
])
d="{{ $ribbon['d'] }}"
fill="{{ $linkColor === 'gradient' ? 'url(#'.$ribbon['id'].')' : 'currentColor' }}"
data-from="{{ $ribbon['from'] }}"
data-to="{{ $ribbon['to'] }}"
data-value="{{ $format($ribbon['value']) }}"
/>
@endforeach
</g>
{{-- Nodes + labels. The first column's labels sit in the left gutter;
every other label reads to the right of its node on a page-coloured
halo so it stays legible over a ribbon. --}}
@foreach ($nodes as $n)
@php($g = $geom[$n['id']])
@php($col = $colOf[$n['id']])
@php($nx = $colX[$col])
@php($isFirst = $col === 0)
@php($lx = $isFirst ? $nx - 8 : $nx + $nodeW + 8)
@php($ly = $g['y'] + $g['h'] / 2)
<g data-slot="chart-sankey-node-group" data-node="{{ $n['id'] }}">
<rect
data-slot="chart-sankey-node"
class="{{ $colorOf[$n['id']] }}"
x="{{ round($nx, 2) }}"
y="{{ round($g['y'], 2) }}"
width="{{ $nodeW }}"
height="{{ round($g['h'], 2) }}"
rx="3"
fill="currentColor"
data-label="{{ $n['label'] }}"
data-total="{{ $format($weight[$n['id']]) }}"
/>
<text
data-slot="chart-sankey-label"
class="pointer-events-none fill-foreground stroke-background text-2xs font-medium [paint-order:stroke] [stroke-linejoin:round]"
stroke-width="3"
x="{{ round($lx, 2) }}"
y="{{ round($showValues ? $ly - 1 : $ly, 2) }}"
text-anchor="{{ $isFirst ? 'end' : 'start' }}"
dominant-baseline="{{ $showValues ? 'auto' : 'middle' }}"
>{{ $n['label'] }}</text>
@if ($showValues)
<text
data-slot="chart-sankey-value"
class="pointer-events-none fill-muted-foreground stroke-background text-2xs tabular-nums [paint-order:stroke] [stroke-linejoin:round]"
stroke-width="3"
x="{{ round($lx, 2) }}"
y="{{ round($ly + 3, 2) }}"
text-anchor="{{ $isFirst ? 'end' : 'start' }}"
dominant-baseline="hanging"
>{{ $format($weight[$n['id']]) }}</text>
@endif
</g>
@endforeach
</svg>
</div>
/**
* Sankey chart hover tooltips.
*
* The Sankey itself is dependency-free static SVG; this adds a lightweight
* pointer tooltip that reads the data-* attributes the Blade stamps onto each
* flow ribbon (data-from / data-to / data-value) and node (data-label /
* data-total). A single delegated listener on the SVG drives it, and both
* listeners are removed on teardown so unmounting mid-hover can't leak.
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiChartSankey', () => ({
open: false,
label: '',
value: '',
x: 0,
y: 0,
svg: null,
onMove: null,
onLeave: null,
init() {
this.svg = this.$root.querySelector('svg');
if (!this.svg) {
return;
}
this.onMove = (event) => {
const target = event.target.closest(
'[data-slot="chart-sankey-link"], [data-slot="chart-sankey-node"]',
);
if (!target) {
this.open = false;
return;
}
const rect = this.$root.getBoundingClientRect();
this.x = event.clientX - rect.left;
this.y = event.clientY - rect.top;
if (target.dataset.from) {
this.label = `${target.dataset.from} → ${target.dataset.to}`;
this.value = target.dataset.value;
this.focus(null, target);
} else {
this.label = target.dataset.label ?? '';
this.value = target.dataset.total ?? '';
this.focus(target.closest('[data-node]')?.dataset.node ?? null, null);
}
this.open = true;
};
this.onLeave = () => {
this.open = false;
this.focus(null, null);
};
this.svg.addEventListener('mousemove', this.onMove);
this.svg.addEventListener('mouseleave', this.onLeave);
},
// Stamp `data-active` on the hovered ribbon (or every ribbon touching
// the hovered node) and `data-dimmed` on the host; ui.css fades the
// rest. Cheap enough to run on every mousemove: a Sankey has tens of
// ribbons, not thousands.
focus(nodeId, link) {
const links = this.svg.querySelectorAll('[data-slot="chart-sankey-link"]');
let any = false;
links.forEach((el) => {
const active = link ? el === link : nodeId !== null && (el.dataset.source === nodeId || el.dataset.target === nodeId);
el.toggleAttribute('data-active', active);
any = any || active;
});
this.$root.toggleAttribute('data-dimmed', any);
},
destroy() {
if (!this.svg) {
return;
}
this.svg.removeEventListener('mousemove', this.onMove);
this.svg.removeEventListener('mouseleave', this.onLeave);
this.svg = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0