Skip to content
Brok UI

Loading…

No results

Charts

Open source

Token-themed data visualisations — the core multi-series chart plus dependency-free SVG variants for sankey, word-cloud, venn and violin plots.

Version
v1.1.1
Stability
stable
License
MIT
Related
Chart
Chart Tooltip
5 of 7

Word Cloud — 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.

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.

previews.components.chart-wordcloud.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/chart-wordcloud.blade.php
  • js resources/js/ui/chart-wordcloud.js
Registry dependencies
chart-tooltip
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

chart-wordcloud.md
# 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

rotated.blade.php Blade
<x-ui.chart-wordcloud layout="spiral" rotate palette="mono" :height="360" label="{{ __('Top topics, packed with vertical words') }}" />
spiral.blade.php Blade
<x-ui.chart-wordcloud layout="spiral" :height="360" label="{{ __('Top topics, packed') }}" />
tags.blade.php Blade
<x-ui.chart-wordcloud layout="tags" :height="200" label="{{ __('Top topics as tags') }}" />
topics.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

chart-tooltip-row chart-wordcloud chart-wordcloud-word

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

Chart and visual analytics

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
root chart-wordcloud-word
Theming hooks
chart-tooltip-row chart-wordcloud chart-wordcloud-word

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/chart-wordcloud.blade.php Blade
@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>
resources/js/ui/chart-wordcloud.js JS
/**
 * 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