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 word cloud. Pass `:words` (each with a `text` and an integer `weight`) — heavier words render larger and cluster centrally; hover any word for its weight. Pick `layout="spiral"` for a packed cloud or `layout="tags"` for chips.
<div class="space-y-4">
<x-ui.chart-wordcloud :height="320" label="{{ __('Top topics') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free word cloud. Pass `:words` (each with a `text` and an integer `weight`) — heavier words render larger and cluster centrally; hover any word for its weight. Pick `layout="spiral"` for a packed cloud or `layout="tags"` for chips.') }}
</p>
</div>
Installation
php artisan ui:add chart-wordcloud
Note
This component ships an Alpine behavior module at
resources/js/ui/chart-wordcloud.js. Import it once from your bundle so it registers on alpine:init:
import './chart-wordcloud.js';
Registry contract
php artisan ui:add chart-wordcloud
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-wordcloud.blade.php -
resources/js/ui/chart-wordcloud.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: Word Cloud (`chart-wordcloud`)
A dependency-free, token-themed word cloud that sizes each term by weight and clusters heavier words centrally, with a hover tooltip showing each word and its weight.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add chart-wordcloud
```
## Usage
```blade
<div class="space-y-4">
<x-ui.chart-wordcloud :height="320" label="{{ __('Top topics') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free word cloud. Pass `:words` (each with a `text` and an integer `weight`) — heavier words render larger and cluster centrally; hover any word for its weight. Pick `layout="spiral"` for a packed cloud or `layout="tags"` for chips.') }}
</p>
</div>
```
## Props
- `words` (mixed|null, default `null`) — Terms to plot: each an object with text and a numeric weight; heavier weight renders larger and more central. Defaults to demo product/docs terms.
- `height` (int, default `320`) — SVG or container height in pixels; width fills the container.
- `label` (string, default `Word cloud`) — Accessible name for the figure.
- `layout` (string, default `flow`) — flow wraps a line with the heaviest words centred; spiral packs words on an Archimedean spiral; tags renders a chip cloud where each chip carries the weight as a count.
- `rotate` (bool, default `false`) — Spiral layout only: turns every third smaller word vertical, the classic cloud look.
- `palette` (string, default `chart`) — chart cycles the neutral chart palette; mono keeps one foreground hue and fades lighter words; primary tints by weight.
## Use when
- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Showing relative term frequency or importance, such as popular tags or topics, at a glance.
- Wanting a flowed, spiralled or chip-style cloud layout without pulling in a charting library.
## 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.
- Precise comparison between values matters; word clouds communicate rough weight, not exact numbers, so a bar chart reads more accurately.
- There are many near-equal-weight terms; the size differences stop being meaningful past a handful of dominant words.
## Anti-patterns
- Decorative charts without a meaningful question
## Rules
- Use the `<brok:chart-wordcloud>` tag (or `<x-ui.chart-wordcloud>`) 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-wordcloud
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chart-wordcloud.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.chart-wordcloud layout="spiral" rotate palette="mono" :height="360" label="{{ __('Top topics, packed with vertical words') }}" />
<x-ui.chart-wordcloud layout="spiral" :height="360" label="{{ __('Top topics, packed') }}" />
<x-ui.chart-wordcloud layout="tags" :height="200" label="{{ __('Top topics as tags') }}" />
{{-- The default cloud framed by chart-card with a heading and a term-count badge. --}}
<x-ui.chart-card heading="Top topics" subheading="Mentions across docs & support" trend="+18 terms">
<x-ui.chart-wordcloud :height="320" label="{{ __('Most-mentioned topics') }}" />
</x-ui.chart-card>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| words | mixed | null | null | Terms to plot: each an object with text and a numeric weight; heavier weight renders larger and more central. Defaults to demo product/docs terms. |
| height | int | 320 | SVG or container height in pixels; width fills the container. |
| label | string | Word cloud | Accessible name for the figure. |
| layout | string | flow | flow wraps a line with the heaviest words centred; spiral packs words on an Archimedean spiral; tags renders a chip cloud where each chip carries the weight as a count. |
| rotate | bool | false | Spiral layout only: turns every third smaller word vertical, the classic cloud look. |
| palette | string | chart | chart cycles the neutral chart palette; mono keeps one foreground hue and fades lighter words; primary tints by weight. |
Slots
default— Unused; the cloud renders from the words 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.
- Sorts words heaviest first so every layout places the important terms before the rest.
- Hovering a word shows a chart-tooltip with its text and weight.
- 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 relative term frequency or importance, such as popular tags or topics, at a glance.
- Wanting a flowed, spiralled or chip-style cloud layout without pulling in a charting library.
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.
- Precise comparison between values matters; word clouds communicate rough weight, not exact numbers, so a bar chart reads more accurately.
- There are many near-equal-weight terms; the size differences stop being meaningful past a handful of dominant words.
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.
- Word size is reinforced by the hover tooltip's exact weight, so the relative-size signal is never the only way to read a term's importance.
- 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-wordcloud-{{ $record->id }}">
<div class="space-y-4">
<x-ui.chart-wordcloud :height="320" label="{{ __('Top topics') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free word cloud. Pass `:words` (each with a `text` and an integer `weight`) — heavier words render larger and cluster centrally; hover any word for its weight. Pick `layout="spiral"` for a packed cloud or `layout="tags"` for chips.') }}
</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([
// Words: each ['text' => string, 'weight' => int]. Heavier weight = larger + central.
'words' => null,
'height' => 320,
'label' => 'Word cloud',
// Layout: `flow` (a wrapped line with the heaviest words in the middle),
// `spiral` (a packed SVG cloud placed on an Archimedean spiral) or
// `tags` (a chip cloud where the chip carries the weight as a count).
'layout' => 'flow',
// Spiral only: turn every third smaller word vertical, the classic cloud look.
'rotate' => false,
// Colour: `chart` cycles the neutral chart palette, `mono` keeps one
// foreground hue and fades lighter words, `primary` tints by weight.
'palette' => 'chart',
])
@php
// ── Demo data (a product / tech docs theme) so it renders without args ──
$words ??= [
['text' => __('Laravel'), 'weight' => 100],
['text' => __('Blade'), 'weight' => 88],
['text' => __('Components'), 'weight' => 80],
['text' => __('Tokens'), 'weight' => 72],
['text' => __('Registry'), 'weight' => 66],
['text' => __('Accessible'), 'weight' => 60],
['text' => __('Dark mode'), 'weight' => 54],
['text' => __('RTL'), 'weight' => 48],
['text' => __('Alpine'), 'weight' => 44],
['text' => __('Tailwind'), 'weight' => 40],
['text' => __('Variants'), 'weight' => 36],
['text' => __('Blocks'), 'weight' => 33],
['text' => __('Primitives'), 'weight' => 30],
['text' => __('Charts'), 'weight' => 27],
['text' => __('Forms'), 'weight' => 24],
['text' => __('Theming'), 'weight' => 22],
['text' => __('Responsive'), 'weight' => 20],
['text' => __('Slots'), 'weight' => 17],
['text' => __('CLI'), 'weight' => 15],
['text' => __('Composer'), 'weight' => 12],
['text' => __('Mirror'), 'weight' => 10],
['text' => __('Docs'), 'weight' => 8],
];
$layout = in_array($layout, ['flow', 'spiral', 'tags'], true) ? $layout : 'flow';
$palette = in_array($palette, ['chart', 'mono', 'primary'], true) ? $palette : 'chart';
$rotate = filter_var($rotate, FILTER_VALIDATE_BOOLEAN);
// Heaviest first: every layout places the important words before the rest.
$words = collect($words)
->map(fn ($w) => ['text' => (string) $w['text'], 'weight' => (int) $w['weight']])
->sortByDesc('weight')
->values()
->all();
$maxWeight = max(1, ...array_column($words, 'weight'));
$minWeight = min(...array_column($words, 'weight'));
// Colour classes are fixed utilities (JIT-visible). `chart` cycles the
// neutral `--chart-1..5` hues; `mono` fades lighter words on one hue;
// `primary` tints heavier words stronger.
$chartPalette = ['text-chart-1', 'text-chart-2', 'text-chart-3', 'text-chart-4', 'text-chart-5'];
$colorFor = function (int $i, int $weight) use ($palette, $chartPalette, $maxWeight, $minWeight): string {
$t = $maxWeight > $minWeight ? ($weight - $minWeight) / ($maxWeight - $minWeight) : 1;
return match ($palette) {
'mono' => match (true) {
$t >= 0.66 => 'text-foreground',
$t >= 0.33 => 'text-foreground/70',
default => 'text-muted-foreground',
},
'primary' => match (true) {
$t >= 0.66 => 'text-primary',
$t >= 0.33 => 'text-primary/75',
default => 'text-primary/50',
},
default => $chartPalette[$i % count($chartPalette)],
};
};
// Bucket weight → a fixed Tailwind text-size class so the JIT sees concrete
// classes (never splice a computed size into an arbitrary class).
$sizeFor = function (int $weight): string {
return match (true) {
$weight >= 85 => 'text-5xl',
$weight >= 70 => 'text-4xl',
$weight >= 55 => 'text-2xl',
$weight >= 40 => 'text-lg',
$weight >= 25 => 'text-base',
$weight >= 14 => 'text-sm',
default => 'text-xs',
};
};
// ── flow: interleave so the heaviest words land mid-list (and therefore in
// the middle rows of the wrap) instead of always leading the first line.
if ($layout === 'flow') {
$ordered = [];
foreach ($words as $i => $w) {
$w['i'] = $i;
if ($i % 2 === 0) {
array_unshift($ordered, $w);
} else {
$ordered[] = $w;
}
}
$flowWords = $ordered;
}
// ── spiral: pack estimated text boxes along an Archimedean spiral from the
// centre out (heaviest first), rejecting any placement that overlaps a
// placed box or leaves the frame. Text metrics are estimated (0.58em per
// character for a sans face) with a small pad so a wider glyph run does
// not collide.
if ($layout === 'spiral') {
$W = 760;
$H = max(160, (int) $height);
$cx = $W / 2;
$cy = $H / 2;
$minPx = 12;
$maxPx = 54;
$placed = [];
$spiralWords = [];
foreach ($words as $i => $w) {
$t = $maxWeight > $minWeight ? ($w['weight'] - $minWeight) / ($maxWeight - $minWeight) : 1;
$px = (int) round($minPx + ($maxPx - $minPx) * sqrt($t));
$vertical = $rotate && $i > 2 && $i % 3 === 0;
$tw = mb_strlen($w['text']) * $px * 0.58 + 8;
$th = $px * 1.08 + 4;
[$bw, $bh] = $vertical ? [$th, $tw] : [$tw, $th];
$found = null;
for ($step = 0; $step < 1200; $step++) {
$a = $step * 0.32;
$r = 2.2 * $a;
$x = $cx + $r * cos($a) - $bw / 2;
$y = $cy + $r * 0.62 * sin($a) - $bh / 2; // squash so it fills a wide frame
if ($x < 0 || $y < 0 || $x + $bw > $W || $y + $bh > $H) {
continue;
}
$hit = false;
foreach ($placed as $p) {
if ($x < $p[0] + $p[2] && $x + $bw > $p[0] && $y < $p[1] + $p[3] && $y + $bh > $p[1]) {
$hit = true;
break;
}
}
if (! $hit) {
$found = [$x, $y];
break;
}
}
if ($found === null) {
continue; // no room left: drop the lightest words rather than overlap
}
$placed[] = [$found[0], $found[1], $bw, $bh];
$spiralWords[] = [
'text' => $w['text'],
'weight' => $w['weight'],
'x' => round($found[0] + $bw / 2, 2),
'y' => round($found[1] + $bh / 2, 2),
'px' => $px,
'vertical' => $vertical,
'color' => $colorFor($i, $w['weight']),
'bold' => $t >= 0.4,
];
}
}
@endphp
<div
x-data="uiChartWordcloud()"
data-slot="chart-wordcloud"
data-layout="{{ $layout }}"
role="img"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => 'relative w-full']) }}
>
{{-- Pointer tooltip; reads the data-* attributes each word 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>
@if ($layout === 'spiral')
<svg
viewBox="0 0 {{ $W }} {{ $H }}"
width="100%"
height="{{ $H }}"
preserveAspectRatio="xMidYMid meet"
class="h-auto w-full"
aria-hidden="true"
>
@foreach ($spiralWords as $word)
<text
data-slot="chart-wordcloud-word"
class="{{ $word['color'] }} cursor-default fill-current transition-opacity duration-200 hover:opacity-70 {{ $word['bold'] ? 'font-semibold' : 'font-medium' }}"
x="{{ $word['x'] }}"
y="{{ $word['y'] }}"
font-size="{{ $word['px'] }}"
text-anchor="middle"
dominant-baseline="central"
@if ($word['vertical']) transform="rotate(-90 {{ $word['x'] }} {{ $word['y'] }})" @endif
data-text="{{ $word['text'] }}"
data-weight="{{ $word['weight'] }}"
>{{ $word['text'] }}</text>
@endforeach
</svg>
@elseif ($layout === 'tags')
<div
class="flex h-full w-full flex-wrap items-center justify-center gap-2 px-2 py-4"
style="min-height:{{ (int) $height }}px"
>
@foreach ($words as $i => $word)
@php($t = $maxWeight > $minWeight ? ($word['weight'] - $minWeight) / ($maxWeight - $minWeight) : 1)
<span
data-slot="chart-wordcloud-word"
@class([
'inline-flex cursor-default items-center gap-2 rounded-full border transition-colors duration-200',
'border-transparent bg-primary text-primary-foreground px-4 py-2 text-base font-semibold' => $t >= 0.66,
'border-border bg-muted text-foreground px-3 py-1 text-sm font-medium hover:bg-accent' => $t >= 0.33 && $t < 0.66,
'border-border bg-background text-muted-foreground px-3 py-1 text-xs font-medium hover:bg-muted' => $t < 0.33,
])
data-text="{{ $word['text'] }}"
data-weight="{{ $word['weight'] }}"
>
{{ $word['text'] }}
<span class="tabular-nums opacity-70">{{ $word['weight'] }}</span>
</span>
@endforeach
</div>
@else
<div
class="flex h-full w-full flex-wrap items-baseline justify-center gap-x-4 gap-y-1 px-2 py-4"
style="min-height:{{ (int) $height }}px"
>
@foreach ($flowWords as $word)
<span
data-slot="chart-wordcloud-word"
class="{{ $colorFor($word['i'], $word['weight']) }} {{ $sizeFor($word['weight']) }} cursor-default leading-tight transition-opacity duration-200 hover:opacity-70 {{ $word['weight'] >= 40 ? 'font-semibold' : 'font-medium' }}"
data-text="{{ $word['text'] }}"
data-weight="{{ $word['weight'] }}"
>{{ $word['text'] }}</span>
@endforeach
</div>
@endif
</div>
/**
* Word cloud hover tooltips.
*
* The word cloud itself is dependency-free static Blade (a flex wrap of token-
* coloured words); this adds a lightweight pointer tooltip that reads the data-*
* attributes the Blade stamps onto each word (data-text / data-weight). A single
* delegated listener on the host element 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('uiChartWordcloud', () => ({
open: false,
label: '',
value: '',
x: 0,
y: 0,
host: null,
onMove: null,
onLeave: null,
init() {
this.host = this.$root;
if (!this.host) {
return;
}
this.onMove = (event) => {
const target = event.target.closest('[data-slot="chart-wordcloud-word"]');
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.text ?? '';
this.value = target.dataset.weight ?? '';
this.open = true;
};
this.onLeave = () => {
this.open = false;
};
this.host.addEventListener('mousemove', this.onMove);
this.host.addEventListener('mouseleave', this.onLeave);
},
destroy() {
if (!this.host) {
return;
}
this.host.removeEventListener('mousemove', this.onMove);
this.host.removeEventListener('mouseleave', this.onLeave);
this.host = null;
},
}));
});
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