Charts
Token-themed data visualisations — the core multi-series chart plus dependency-free SVG variants for sankey, word-cloud, venn and violin plots.
Violin Chart — A dependency-free, token-themed SVG violin plot that mirrors each group's density profile left and right of a centre axis, with a median marker and baseline group labels.
Preview
A dependency-free SVG violin plot. Pass `:groups` — each `[label, density, range]` with optional `median`, `q1` and `q3` in chart units — where `density` is the half-width profile sampled low→high across `range`; the value axis, gridlines and quartile box come from the data.
<div class="space-y-4">
<x-ui.chart-violin :height="320" unit="ms" label="{{ __('Response latency per quarter') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG violin plot. Pass `:groups` — each `[label, density, range]` with optional `median`, `q1` and `q3` in chart units — where `density` is the half-width profile sampled low→high across `range`; the value axis, gridlines and quartile box come from the data.') }}
</p>
</div>
Installation
php artisan ui:add chart-violin
Registry contract
php artisan ui:add chart-violin
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-violin.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
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: Violin Chart (`chart-violin`)
A dependency-free, token-themed SVG violin plot that mirrors each group's density profile left and right of a centre axis, with a median marker and baseline group labels.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add chart-violin
```
## Usage
```blade
<div class="space-y-4">
<x-ui.chart-violin :height="320" unit="ms" label="{{ __('Response latency per quarter') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG violin plot. Pass `:groups` — each `[label, density, range]` with optional `median`, `q1` and `q3` in chart units — where `density` is the half-width profile sampled low→high across `range`; the value axis, gridlines and quartile box come from the data.') }}
</p>
</div>
```
## Props
- `groups` (mixed|null, default `null`) — Groups to plot: each an object with label, a density profile (half-width samples low to high across range), range [low, high], and optional median, q1 and q3. Defaults to demo latency data.
- `domain` (mixed|null, default `null`) — Value-axis domain [low, high]; defaults to the union of every group's range.
- `ticks` (int, default `5`) — Number of axis tick marks.
- `unit` (string, default ``) — Suffix appended to tick labels and readouts, such as "ms".
- `showBox` (bool, default `true`) — Draws the inner quartile box and median dot when q1, q3 and median are given.
- `height` (int, default `320`) — SVG height in pixels; width fills the container.
- `label` (string, default `Violin chart`) — Accessible name for the figure.
## Use when
- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Comparing the full distribution shape of a value across groups, such as latency per quarter, not just their averages.
- Wanting quartile and median markers layered on top of the distribution shape in one figure.
## 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.
- Only a single summary statistic per group is needed; use chart's bar type instead.
- The distribution's density profile is not already known or computable; a box plot or bar chart needs less input data.
## Anti-patterns
- Decorative charts without a meaningful question
## Rules
- Use the `<brok:chart-violin>` tag (or `<x-ui.chart-violin>`) 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-violin
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chart-violin.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 density plot framed by chart-card; a falling latency is good
news, so the badge tone is set by hand. --}}
<x-ui.chart-card heading="Response time distribution" subheading="Latency density by quarter (ms)" trend="−12ms" trend-variant="success">
<x-ui.chart-violin :height="320" label="{{ __('Latency density by quarter') }}" />
</x-ui.chart-card>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | mixed | null | null | Groups to plot: each an object with label, a density profile (half-width samples low to high across range), range [low, high], and optional median, q1 and q3. Defaults to demo latency data. |
| domain | mixed | null | null | Value-axis domain [low, high]; defaults to the union of every group's range. |
| ticks | int | 5 | Number of axis tick marks. |
| unit | string | Suffix appended to tick labels and readouts, such as "ms". | |
| showBox | bool | true | Draws the inner quartile box and median dot when q1, q3 and median are given. |
| height | int | 320 | SVG height in pixels; width fills the container. |
| label | string | Violin chart | Accessible name for the figure. |
Slots
default— Unused; the chart renders from the groups prop only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders without arguments using built-in demo data, so it previews immediately.
- Mirrors each group's density profile left and right of a centre axis to form the violin shape, and overlays a quartile box and median marker when showBox and quartile values are given.
- Dependency-free SVG: no chart library, computed and laid out entirely in Blade.
- Declares registry capability flags: a11y, 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.
- Comparing the full distribution shape of a value across groups, such as latency per quarter, not just their averages.
- Wanting quartile and median markers layered on top of the distribution shape in one figure.
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.
- Only a single summary statistic per group is needed; use chart's bar type instead.
- The distribution's density profile is not already known or computable; a box plot or bar chart needs less input data.
Use instead
- Table for exact lookup
- Stat for a single value
Anti-patterns
- Decorative charts without a meaningful question
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- No component-owned keyboard interaction; native element behavior applies.
- 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.
- Group labels render as visible baseline text so each violin is identifiable without relying on colour or position alone.
- 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
Livewire can update this component through forwarded wire:* attributes.
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Groups: each ['label' => string, 'density' => [floats], 'range' => [low, high],
// 'median' => value, 'q1' => value, 'q3' => value].
// `density` is the half-width profile sampled low→high across `range`
// (values in the chart's units); `median`/`q1`/`q3` are also in units.
'groups' => null,
// Value-axis domain [low, high]; defaults to the union of the group ranges.
'domain' => null,
'ticks' => 5,
// Suffix for tick labels + readouts (e.g. "ms").
'unit' => '',
// Draw the inner quartile box + median dot when q1/q3/median are given.
'showBox' => true,
'height' => 320,
'label' => 'Violin chart',
])
@php
// ── Demo data (response latency per quarter, in ms) so it renders without args ──
$groups ??= [
['label' => __('Q1'), 'range' => [40, 360], 'density' => [0.02, 0.10, 0.30, 0.62, 0.92, 1.00, 0.84, 0.58, 0.34, 0.18, 0.09, 0.04, 0.01], 'q1' => 118, 'median' => 152, 'q3' => 196],
['label' => __('Q2'), 'range' => [50, 300], 'density' => [0.02, 0.14, 0.40, 0.78, 1.00, 0.90, 0.60, 0.32, 0.14, 0.05, 0.01], 'q1' => 112, 'median' => 138, 'q3' => 168],
['label' => __('Q3'), 'range' => [30, 250], 'density' => [0.03, 0.18, 0.52, 0.90, 1.00, 0.74, 0.44, 0.22, 0.10, 0.04, 0.01], 'q1' => 84, 'median' => 108, 'q3' => 136],
['label' => __('Q4'), 'range' => [20, 200], 'density' => [0.02, 0.22, 0.66, 1.00, 0.86, 0.52, 0.26, 0.12, 0.05, 0.01], 'q1' => 58, 'median' => 76, 'q3' => 98],
];
$showBox = filter_var($showBox, FILTER_VALIDATE_BOOLEAN);
$ticks = max(2, (int) $ticks);
// ── Domain ─────────────────────────────────────────────────────────────
if (! is_array($domain) || count($domain) < 2) {
$lows = $highs = [];
foreach ($groups as $g) {
$r = $g['range'] ?? [0, 1];
$lows[] = (float) $r[0];
$highs[] = (float) $r[1];
}
$domain = [min($lows ?: [0]), max($highs ?: [1])];
}
[$dLo, $dHi] = [(float) $domain[0], (float) $domain[1]];
if ($dHi <= $dLo) {
$dHi = $dLo + 1;
}
// ── Geometry ───────────────────────────────────────────────────────────
$W = 720;
$H = max(160, (int) $height);
$axisW = 44; // room for tick labels on the start side
$top = 12;
$axisY = $H - 28;
$plotH = $axisY - $top;
$plotX0 = $axisW;
$plotW = $W - $axisW - 8;
$count = max(1, count($groups));
$colW = $plotW / $count;
$halfMax = min($colW, 120) / 2 - 10;
$yFor = fn (float $v) => $axisY - (($v - $dLo) / ($dHi - $dLo)) * $plotH;
$palette = ['text-chart-1', 'text-chart-2', 'text-chart-3', 'text-chart-4', 'text-chart-5'];
$fmt = fn (float $v) => (floor($v) == $v ? number_format($v) : number_format($v, 1)).$unit;
// Catmull-Rom → cubic Bézier, so the density outline is a smooth curve
// rather than a polyline through the samples.
$smooth = function (array $pts): string {
$n = count($pts);
if ($n < 2) {
return '';
}
$d = '';
for ($i = 0; $i < $n - 1; $i++) {
$p0 = $pts[max(0, $i - 1)];
$p1 = $pts[$i];
$p2 = $pts[$i + 1];
$p3 = $pts[min($n - 1, $i + 2)];
$c1x = $p1[0] + ($p2[0] - $p0[0]) / 6;
$c1y = $p1[1] + ($p2[1] - $p0[1]) / 6;
$c2x = $p2[0] - ($p3[0] - $p1[0]) / 6;
$c2y = $p2[1] - ($p3[1] - $p1[1]) / 6;
$d .= sprintf('C%.2f %.2f %.2f %.2f %.2f %.2f ', $c1x, $c1y, $c2x, $c2y, $p2[0], $p2[1]);
}
return $d;
};
$violins = [];
foreach (array_values($groups) as $i => $g) {
$density = array_values(array_map('floatval', $g['density'] ?? []));
$n = count($density);
if ($n < 2) {
continue;
}
[$lo, $hi] = array_map('floatval', $g['range'] ?? [$dLo, $dHi]);
$cx = $plotX0 + $colW * ($i + 0.5);
$peak = max($density) ?: 1.0;
$right = $left = [];
foreach ($density as $k => $dv) {
$v = $lo + ($k / ($n - 1)) * ($hi - $lo);
$y = $yFor($v);
$hw = ($dv / $peak) * $halfMax;
$right[] = [$cx + $hw, $y];
$left[] = [$cx - $hw, $y];
}
// Closed outline: up the right edge, down the left, pinched to the
// centre line at both ends so the tails taper.
$path = sprintf('M%.2f %.2f ', $cx, $yFor($lo))
.$smooth(array_merge([[$cx, $yFor($lo)]], $right, [[$cx, $yFor($hi)]]))
.$smooth(array_merge([[$cx, $yFor($hi)]], array_reverse($left), [[$cx, $yFor($lo)]]))
.'Z';
$violins[] = [
'label' => $g['label'] ?? '',
'color' => $palette[$i % count($palette)],
'd' => $path,
'cx' => $cx,
'lo' => $yFor($lo),
'hi' => $yFor($hi),
'median' => isset($g['median']) ? $yFor((float) $g['median']) : null,
'q1' => isset($g['q1']) ? $yFor((float) $g['q1']) : null,
'q3' => isset($g['q3']) ? $yFor((float) $g['q3']) : null,
'summary' => isset($g['median']) ? __('median :value', ['value' => $fmt((float) $g['median'])]) : '',
];
}
// "Nice" tick steps (1/2/5 × 10ⁿ) inside the domain, so the axis reads
// 50/100/150 rather than 20/105/190.
$rawStep = ($dHi - $dLo) / ($ticks - 1);
$mag = 10 ** floor(log10($rawStep));
$step = collect([1, 2, 2.5, 5, 10])->map(fn ($m) => $m * $mag)->first(fn ($st) => $st >= $rawStep);
$tickValues = [];
for ($tv = ceil($dLo / $step) * $step; $tv <= $dHi + 1e-9; $tv += $step) {
$tickValues[] = $tv;
}
@endphp
<div
data-slot="chart-violin"
role="img"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => 'relative w-full text-foreground']) }}
>
<svg
viewBox="0 0 {{ $W }} {{ $H }}"
width="100%"
height="{{ $H }}"
preserveAspectRatio="xMidYMid meet"
class="h-auto w-full"
fill="none"
aria-hidden="true"
>
{{-- Value axis: hairline gridlines + muted tick labels. --}}
@foreach ($tickValues as $tv)
@php($ty = $yFor($tv))
<line
class="stroke-border"
x1="{{ $plotX0 }}"
y1="{{ round($ty, 2) }}"
x2="{{ $W - 8 }}"
y2="{{ round($ty, 2) }}"
stroke="currentColor"
stroke-width="1"
/>
<text
class="fill-muted-foreground text-2xs tabular-nums"
x="{{ $plotX0 - 8 }}"
y="{{ round($ty, 2) }}"
text-anchor="end"
dominant-baseline="middle"
>{{ $fmt($tv) }}</text>
@endforeach
@foreach ($violins as $v)
<g data-slot="chart-violin-group" class="{{ $v['color'] }}">
{{-- Density body, mirrored left/right of the centre axis. --}}
<path
data-slot="chart-violin-body"
class="transition-opacity duration-200 hover:opacity-90"
d="{{ $v['d'] }}"
fill="currentColor"
fill-opacity="0.35"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
/>
@if ($showBox && $v['q1'] !== null && $v['q3'] !== null)
{{-- Interquartile box + whisker to the range ends. --}}
<line
class="stroke-foreground/50"
x1="{{ round($v['cx'], 2) }}"
y1="{{ round($v['lo'], 2) }}"
x2="{{ round($v['cx'], 2) }}"
y2="{{ round($v['hi'], 2) }}"
stroke="currentColor"
stroke-width="1"
/>
<rect
data-slot="chart-violin-box"
class="fill-foreground"
x="{{ round($v['cx'] - 4, 2) }}"
y="{{ round($v['q3'], 2) }}"
width="8"
height="{{ round(max(1, $v['q1'] - $v['q3']), 2) }}"
rx="2"
fill="currentColor"
/>
@endif
@if ($v['median'] !== null)
{{-- Median marker: a page-coloured dot so it reads on the box. --}}
<circle
data-slot="chart-violin-median"
class="fill-background stroke-foreground"
cx="{{ round($v['cx'], 2) }}"
cy="{{ round($v['median'], 2) }}"
r="3"
fill="currentColor"
stroke="currentColor"
stroke-width="1.5"
>
<title>{{ $v['summary'] }}</title>
</circle>
@endif
</g>
{{-- Group label under the axis. --}}
<text
data-slot="chart-violin-label"
class="fill-foreground text-2xs font-medium"
x="{{ round($v['cx'], 2) }}"
y="{{ $axisY + 18 }}"
text-anchor="middle"
dominant-baseline="middle"
>{{ $v['label'] }}</text>
@endforeach
{{-- Baseline axis. --}}
<line
class="stroke-border"
x1="{{ $plotX0 }}"
y1="{{ $axisY }}"
x2="{{ $W - 8 }}"
y2="{{ $axisY }}"
stroke="currentColor"
stroke-width="1"
/>
</svg>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.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