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 Venn diagram. Pass `:sets` — 2 or 3 `[label, value]` pairs — and optional `:intersections` (`sets` indexes + `value`) to size each circle by its value and label every overlap.
<div class="space-y-4">
<x-ui.chart-venn :height="320" label="{{ __('Channel overlap') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG Venn diagram. Pass `:sets` — 2 or 3 `[label, value]` pairs — and optional `:intersections` (`sets` indexes + `value`) to size each circle by its value and label every overlap.') }}
</p>
</div>
Installation
php artisan ui:add chart-venn
Note
This component ships an Alpine behavior module at
resources/js/ui/chart-venn.js. Import it once from your bundle so it registers on alpine:init:
import './chart-venn.js';
Registry contract
php artisan ui:add chart-venn
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-venn.blade.php -
resources/js/ui/chart-venn.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: Venn Diagram (`chart-venn`)
A dependency-free, token-themed SVG Venn diagram for 2 or 3 sets, with translucent overlap lobes and a hover tooltip showing each set's label and value.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add chart-venn
```
## Usage
```blade
<div class="space-y-4">
<x-ui.chart-venn :height="320" label="{{ __('Channel overlap') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG Venn diagram. Pass `:sets` — 2 or 3 `[label, value]` pairs — and optional `:intersections` (`sets` indexes + `value`) to size each circle by its value and label every overlap.') }}
</p>
</div>
```
## Props
- `sets` (mixed|null, default `null`) — 2 or 3 sets: each an object with label and a numeric value. Defaults to demo channel-reach data.
- `intersections` (mixed|null, default `null`) — Optional overlap counts: each an object with sets (0-based indexes into sets, e.g. [0, 1, 2] for the triple overlap) and a numeric value.
- `height` (int, default `320`) — SVG height in pixels; the viewBox fits the circles.
- `label` (string, default `Venn diagram`) — Accessible name for the figure.
- `proportional` (bool, default `true`) — Sizes every circle by its value (area-proportional) instead of drawing equal radii.
## Use when
- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Showing how two or three sets overlap, such as customers reached across marketing channels.
- Wanting overlap area to read honestly: a given intersection's lens area matches its value.
## 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.
- There are more than three sets to compare; a Venn diagram cannot stay readable past three circles.
- The sets do not meaningfully overlap; use chart's bar type to compare them side by side instead.
## Anti-patterns
- Decorative charts without a meaningful question
## Rules
- Use the `<brok:chart-venn>` tag (or `<x-ui.chart-venn>`) 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-venn
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chart-venn.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- The default three-set diagram framed by chart-card with a neutral count badge. --}}
<x-ui.chart-card heading="Channel overlap" subheading="Users reachable per channel" trend="3 channels" trend-variant="neutral">
<x-ui.chart-venn :height="320" label="{{ __('Users reachable per channel') }}" />
</x-ui.chart-card>
<div class="grid gap-6 sm:grid-cols-2">
<x-ui.chart-venn
:sets="[['label' => __('Trial users'), 'value' => 1240], ['label' => __('Paying'), 'value' => 380]]"
:intersections="[['sets' => [0, 1], 'value' => 296, 'label' => __('Converted')]]"
:height="260"
label="{{ __('Most paying customers came from a trial') }}"
/>
<x-ui.chart-venn
:sets="[['label' => __('Newsletter'), 'value' => 900], ['label' => __('Webinar'), 'value' => 700]]"
:intersections="[['sets' => [0, 1], 'value' => 60]]"
:height="260"
label="{{ __('Newsletter and webinar audiences barely overlap') }}"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sets | mixed | null | null | 2 or 3 sets: each an object with label and a numeric value. Defaults to demo channel-reach data. |
| intersections | mixed | null | null | Optional overlap counts: each an object with sets (0-based indexes into sets, e.g. [0, 1, 2] for the triple overlap) and a numeric value. |
| height | int | 320 | SVG height in pixels; the viewBox fits the circles. |
| label | string | Venn diagram | Accessible name for the figure. |
| proportional | bool | true | Sizes every circle by its value (area-proportional) instead of drawing equal radii. |
Slots
default— Unused; the diagram renders from the sets and intersections 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.
- When an intersection value is given, the two circles are positioned so the overlap lens area matches it, computed by a binary search on the centre distance; without one, circles overlap by a fixed share of their radii.
- Hovering a set or an overlap region shows a chart-tooltip with that item's label and value.
- 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.
- Showing how two or three sets overlap, such as customers reached across marketing channels.
- Wanting overlap area to read honestly: a given intersection's lens area matches its value.
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.
- There are more than three sets to compare; a Venn diagram cannot stay readable past three circles.
- The sets do not meaningfully overlap; use chart's bar type to compare them side by side 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
none
- The figure carries role="img" with an aria-label from label, so a screen reader hears a named summary instead of parsing raw SVG.
- Each region's label and value render as visible text, not only as hover-only tooltip content.
- 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-venn-{{ $record->id }}">
<div class="space-y-4">
<x-ui.chart-venn :height="320" label="{{ __('Channel overlap') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG Venn diagram. Pass `:sets` — 2 or 3 `[label, value]` pairs — and optional `:intersections` (`sets` indexes + `value`) to size each circle by its value and label every overlap.') }}
</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([
// Sets: each ['label' => string, 'value' => number]. Pass 2 OR 3 sets.
'sets' => null,
// Optional overlap counts: each ['sets' => [0, 1], 'value' => number] using
// 0-based indexes into `sets`; [0, 1, 2] labels the triple overlap.
'intersections' => null,
'height' => 320,
'label' => 'Venn diagram',
// Size every circle by its value (area-proportional) instead of equal radii.
'proportional' => true,
])
@php
// ── Demo data so it renders without args ──────────────────────────────────
$sets ??= [
['label' => __('Email'), 'value' => 820],
['label' => __('Push'), 'value' => 540],
['label' => __('SMS'), 'value' => 410],
];
$intersections ??= [
['sets' => [0, 1], 'value' => 210],
['sets' => [1, 2], 'value' => 120],
['sets' => [0, 2], 'value' => 160],
['sets' => [0, 1, 2], 'value' => 64],
];
// Clamp to a 2- or 3-set Venn; ignore anything past the third set.
$sets = array_slice(array_values($sets), 0, 3);
$count = max(2, count($sets));
$proportional = filter_var($proportional, FILTER_VALIDATE_BOOLEAN);
$H = max(160, (int) $height);
$palette = ['text-chart-1', 'text-chart-2', 'text-chart-3'];
// Label fills as fixed utilities (never spliced) so the JIT emits them.
$labelFills = ['fill-chart-1', 'fill-chart-2', 'fill-chart-3'];
// ── Geometry (the viewBox is fitted to the circles below) ──────────────
// Radii follow √value so AREA is proportional to the set size; the largest
// circle is pinned to a fixed radius and the rest scale down from it. When
// a pairwise intersection is given, the two circles are placed so the LENS
// between them has that area (a binary search on the centre distance), so
// the picture states the overlap honestly; without one they overlap by a
// fixed share of their radii.
$values = array_map(fn ($s) => max(0.0, (float) ($s['value'] ?? 0)), $sets);
$maxV = max($values) ?: 1;
$R = $count === 2 ? 92 : 84;
$radii = array_map(fn ($v) => $proportional ? max(28, $R * sqrt($v / $maxV)) : $R, $values);
$areaPerUnit = M_PI * $R * $R / $maxV; // px² per unit of value
$overlap = 0.72;
// Pairwise intersection values keyed "a-b" (a < b).
$pairValue = [];
foreach ((array) $intersections as $ix) {
$ids = array_values(array_unique(array_map('intval', (array) ($ix['sets'] ?? []))));
if (count($ids) === 2) {
sort($ids);
$pairValue[$ids[0].'-'.$ids[1]] = max(0.0, (float) ($ix['value'] ?? 0));
}
}
// Lens area of two circles at centre distance d.
$lens = function (float $r1, float $r2, float $d): float {
if ($d >= $r1 + $r2) {
return 0.0;
}
if ($d <= abs($r1 - $r2)) {
return M_PI * min($r1, $r2) ** 2;
}
$a = $r1 ** 2 * acos(max(-1, min(1, ($d ** 2 + $r1 ** 2 - $r2 ** 2) / (2 * $d * $r1))));
$b = $r2 ** 2 * acos(max(-1, min(1, ($d ** 2 + $r2 ** 2 - $r1 ** 2) / (2 * $d * $r2))));
$c = 0.5 * sqrt(max(0, (-$d + $r1 + $r2) * ($d + $r1 - $r2) * ($d - $r1 + $r2) * ($d + $r1 + $r2)));
return $a + $b - $c;
};
// Centre distance for a pair: solved from the lens area when known.
$distance = function (int $a, int $b) use ($radii, $pairValue, $areaPerUnit, $lens, $overlap, $proportional): float {
$r1 = $radii[$a];
$r2 = $radii[$b];
$key = min($a, $b).'-'.max($a, $b);
if (! $proportional || ! isset($pairValue[$key])) {
return ($r1 + $r2) * $overlap;
}
$target = min($pairValue[$key] * $areaPerUnit, M_PI * min($r1, $r2) ** 2);
// Keep at least a sliver of lens so the pair still reads as related.
$lo = abs($r1 - $r2) + 0.5;
$hi = $r1 + $r2 - 6;
for ($i = 0; $i < 40; $i++) {
$mid = ($lo + $hi) / 2;
if ($lens($r1, $r2, $mid) > $target) {
$lo = $mid;
} else {
$hi = $mid;
}
}
return ($lo + $hi) / 2;
};
$centers = [];
if ($count === 2) {
$d = $distance(0, 1);
$cx = 200;
$cy = 160;
$centers = [[$cx - $d / 2, $cy], [$cx + $d / 2, $cy]];
} else {
// Two on top, one below; each pair's distance comes from its overlap.
$d01 = $distance(0, 1);
$d02 = $distance(0, 2);
$d12 = $distance(1, 2);
// The three distances must form a triangle; stretch the longest apart
// if they do not (a third set that overlaps both far more than they
// overlap each other).
$d01 = max($d01, abs($d02 - $d12) + 1);
$x0 = 0;
$x1 = $d01;
$x2 = ($d02 ** 2 - $d12 ** 2 + $d01 ** 2) / (2 * $d01);
$y2 = sqrt(max(0, $d02 ** 2 - $x2 ** 2));
$centers = [[$x0, 0], [$x1, 0], [$x2, $y2]];
// Centre the triangle's bounding box in the frame.
$minX = min(array_map(fn ($c, $r) => $c[0] - $r, $centers, $radii));
$maxX = max(array_map(fn ($c, $r) => $c[0] + $r, $centers, $radii));
$minY = min(array_map(fn ($c, $r) => $c[1] - $r, $centers, $radii));
$maxY = max(array_map(fn ($c, $r) => $c[1] + $r, $centers, $radii));
$dx = 200 - ($minX + $maxX) / 2;
$dy = 160 - ($minY + $maxY) / 2;
$centers = array_map(fn ($c) => [$c[0] + $dx, $c[1] + $dy], $centers);
}
// The viewBox hugs the circles plus a gutter for the outside labels, so a
// long set name is never clipped by a fixed frame.
$bx0 = min(array_map(fn ($c, $r) => $c[0] - $r, $centers, $radii)) - 104;
$bx1 = max(array_map(fn ($c, $r) => $c[0] + $r, $centers, $radii)) + 104;
$by0 = min(array_map(fn ($c, $r) => $c[1] - $r, $centers, $radii)) - 40;
$by1 = max(array_map(fn ($c, $r) => $c[1] + $r, $centers, $radii)) + 40;
// Set labels sit just outside each circle, pushed away from the diagram's
// centroid so they never land on another set.
$gx = array_sum(array_column($centers, 0)) / $count;
$gy = array_sum(array_column($centers, 1)) / $count;
$labels = [];
foreach ($centers as $i => $c) {
$vx = $c[0] - $gx;
$vy = $c[1] - $gy;
$len = sqrt($vx ** 2 + $vy ** 2) ?: 1;
$ux = $vx / $len;
$uy = $vy / $len;
$labels[] = [
'x' => $c[0] + $ux * ($radii[$i] + 10),
'y' => $c[1] + $uy * ($radii[$i] + 10),
'anchor' => abs($ux) < 0.35 ? 'middle' : ($ux < 0 ? 'end' : 'start'),
'baseline' => abs($uy) < 0.35 ? 'middle' : ($uy < 0 ? 'auto' : 'hanging'),
];
}
// Intersection readouts: a pair label sits on the lens (between the two
// centres, nudged away from the third), the triple in the centroid.
$format = fn ($v) => number_format((float) $v);
$overlaps = [];
foreach ((array) $intersections as $ix) {
$ids = array_values(array_unique(array_map('intval', (array) ($ix['sets'] ?? []))));
$ids = array_values(array_filter($ids, fn ($id) => isset($centers[$id])));
if (count($ids) < 2) {
continue;
}
if (count($ids) >= 3) {
$x = $gx;
$y = $gy;
} else {
[$a, $b] = $ids;
// The lens centre: along the line of centres, midway between the
// two far edges of the overlap (not the midpoint of the centres,
// which sits off-lens when the circles differ in size).
$dd = hypot($centers[$b][0] - $centers[$a][0], $centers[$b][1] - $centers[$a][1]) ?: 1;
$ux = ($centers[$b][0] - $centers[$a][0]) / $dd;
$uy = ($centers[$b][1] - $centers[$a][1]) / $dd;
$from = $dd - $radii[$b]; // lens starts here along the line (from a)
$to = $radii[$a]; // and ends here
$mid = ($from + $to) / 2;
$x = $centers[$a][0] + $ux * $mid;
$y = $centers[$a][1] + $uy * $mid;
if ($count === 3) {
$third = array_values(array_diff([0, 1, 2], $ids))[0];
$vx = $x - $centers[$third][0];
$vy = $y - $centers[$third][1];
$len = sqrt($vx ** 2 + $vy ** 2) ?: 1;
$x += ($vx / $len) * 22;
$y += ($vy / $len) * 22;
}
}
$overlaps[] = [
'x' => $x,
'y' => $y,
'value' => $format($ix['value'] ?? 0),
'label' => $ix['label'] ?? implode(' ∩ ', array_map(fn ($id) => $sets[$id]['label'] ?? '', $ids)),
];
}
@endphp
<div
x-data="uiChartVenn()"
data-slot="chart-venn"
role="img"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => 'relative w-full']) }}
>
{{-- Pointer tooltip; reads the data-* attributes each set circle carries. --}}
<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="{{ round($bx0, 2) }} {{ round($by0, 2) }} {{ round($bx1 - $bx0, 2) }} {{ round($by1 - $by0, 2) }}"
width="100%"
height="{{ $H }}"
preserveAspectRatio="xMidYMid meet"
class="h-auto w-full"
fill="none"
aria-hidden="true"
>
{{-- Set circles: a solid 2px outline in the set's hue over a light fill
of the same hue, so each set keeps its colour and the lenses read
as darker where fills stack. --}}
<g data-slot="chart-venn-sets">
@foreach ($sets as $i => $set)
<circle
data-slot="chart-venn-set"
class="{{ $palette[$i % count($palette)] }} transition-[fill-opacity] duration-200 hover:[fill-opacity:0.4]"
cx="{{ round($centers[$i][0], 2) }}"
cy="{{ round($centers[$i][1], 2) }}"
r="{{ round($radii[$i], 2) }}"
fill="currentColor"
fill-opacity="0.22"
stroke="currentColor"
stroke-width="2"
data-label="{{ $set['label'] }}"
data-value="{{ $format($set['value'] ?? 0) }}"
/>
@endforeach
</g>
{{-- Overlap counts on the lenses. --}}
@foreach ($overlaps as $o)
<text
data-slot="chart-venn-overlap"
class="pointer-events-none fill-foreground text-2xs font-semibold tabular-nums stroke-background [paint-order:stroke] [stroke-linejoin:round]"
stroke-width="3"
x="{{ round($o['x'], 2) }}"
y="{{ round($o['y'], 2) }}"
text-anchor="middle"
dominant-baseline="middle"
data-label="{{ $o['label'] }}"
data-value="{{ $o['value'] }}"
>{{ $o['value'] }}</text>
@endforeach
{{-- Set labels + totals, outside each circle. --}}
@foreach ($sets as $i => $set)
@php($l = $labels[$i])
<text
data-slot="chart-venn-label"
class="pointer-events-none {{ $labelFills[$i % count($labelFills)] }} text-xs font-semibold"
x="{{ round($l['x'], 2) }}"
y="{{ round($l['baseline'] === 'middle' ? $l['y'] - 7 : $l['y'], 2) }}"
text-anchor="{{ $l['anchor'] }}"
dominant-baseline="{{ $l['baseline'] === 'middle' ? 'auto' : $l['baseline'] }}"
>{{ $set['label'] }}</text>
<text
data-slot="chart-venn-value"
class="pointer-events-none fill-muted-foreground text-2xs tabular-nums"
x="{{ round($l['x'], 2) }}"
y="{{ round(($l['baseline'] === 'middle' ? $l['y'] - 7 : $l['y']) + 14, 2) }}"
text-anchor="{{ $l['anchor'] }}"
dominant-baseline="{{ $l['baseline'] === 'middle' ? 'auto' : $l['baseline'] }}"
>{{ $format($set['value'] ?? 0) }}</text>
@endforeach
</svg>
</div>
/**
* Venn chart hover tooltips.
*
* The Venn itself is dependency-free static SVG; this adds a lightweight pointer
* tooltip that reads the data-* attributes the Blade stamps onto each set circle
* (data-label / data-value). A single delegated mousemove listener on the host
* 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('uiChartVenn', () => ({
open: false,
label: '',
value: '',
x: 0,
y: 0,
onMove: null,
onLeave: null,
init() {
this.onMove = (event) => {
const target = event.target.closest('[data-slot="chart-venn-set"]');
if (!target) {
this.open = false;
return;
}
const rect = this.$root.getBoundingClientRect();
this.x = event.clientX - rect.left;
this.y = event.clientY - rect.top;
this.label = target.dataset.label ?? '';
this.value = target.dataset.value ?? '';
this.open = true;
};
this.onLeave = () => {
this.open = false;
};
this.$root.addEventListener('mousemove', this.onMove);
this.$root.addEventListener('mouseleave', this.onLeave);
},
destroy() {
this.$root.removeEventListener('mousemove', this.onMove);
this.$root.removeEventListener('mouseleave', this.onLeave);
},
}));
});
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