Chart Blocks
Chart compositions on the chart-card shell: a KPI metric with a headline figure, a dual-axis column/line combo, a waterfall bridge and a grid of liquid gauges. One chart in a card is chart-card plus a chart example. Comparison instead frames a two-series bar chart in a tray, with a range select swapping the bundled dataset client-side.
Preview
@props([
'title' => 'Total revenue',
'ranges' => null,
'range' => null,
'series' => null,
'categories' => null,
'height' => 320,
])
@php
// Two named series for the "Last 30 days" bundled period — shaped for
// both chart-legend (name/value/delta/tone) and the bar chart
// (name/data). `series`/`categories` seed that one period; `ranges`
// below adds the other two the select swaps between client-side, and
// fully replaces this bundle (including "30d") when supplied.
$series ??= [
[
'name' => __('Revenue'),
'data' => [1950, 2180, 2260, 2360],
'value' => '$8,750.00',
'delta' => 12,
'color' => 'chart-1',
],
[
'name' => __('Expense'),
'data' => [430, 460, 470, 492],
'value' => '$1,852.00',
'delta' => -3,
// A fallen expense is good news even though the delta reads
// negative — override the legend badge's tone without flipping
// the down arrow that shows what actually happened.
'tone' => 'positive',
'color' => 'chart-2',
],
];
$categories ??= [__('Week 1'), __('Week 2'), __('Week 3'), __('Week 4')];
// The full bundle the range select switches between. Each entry carries
// its own categories + two series (revenue, expense) in the same shape
// as `series` above.
$ranges ??= [
'7d' => [
'label' => __('Last 7 days'),
'categories' => [__('Mon'), __('Tue'), __('Wed'), __('Thu'), __('Fri'), __('Sat'), __('Sun')],
'series' => [
['name' => __('Revenue'), 'data' => [1150, 1280, 1340, 1460, 1610, 980, 850], 'value' => '$2,120.00', 'delta' => 8, 'color' => 'chart-1'],
['name' => __('Expense'), 'data' => [260, 280, 300, 310, 340, 190, 170], 'value' => '$420.00', 'delta' => -5, 'tone' => 'positive', 'color' => 'chart-2'],
],
],
'30d' => [
'label' => __('Last 30 days'),
'categories' => $categories,
'series' => $series,
],
'12m' => [
'label' => __('Last 12 months'),
'categories' => [__('Jan'), __('Feb'), __('Mar'), __('Apr'), __('May'), __('Jun'), __('Jul'), __('Aug'), __('Sep'), __('Oct'), __('Nov'), __('Dec')],
'series' => [
['name' => __('Revenue'), 'data' => [5200, 5600, 6100, 6700, 7300, 7900, 8400, 8800, 9200, 9600, 10100, 10700], 'value' => '$96,400.00', 'delta' => 18, 'color' => 'chart-1'],
['name' => __('Expense'), 'data' => [1550, 1600, 1650, 1700, 1750, 1780, 1820, 1850, 1880, 1900, 1930, 1960], 'value' => '$21,340.00', 'delta' => -6, 'tone' => 'positive', 'color' => 'chart-2'],
],
],
];
$activeRange = ($range !== null && array_key_exists($range, $ranges))
? $range
: (array_key_exists('30d', $ranges) ? '30d' : array_key_first($ranges));
$active = $ranges[$activeRange];
// Legend rows for one range, in chart-legend's `items` shape.
$legendItems = function (array $rangeData) {
return collect($rangeData['series'])->map(fn ($s) => [
'label' => $s['name'],
'value' => $s['value'] ?? null,
'delta' => $s['delta'] ?? null,
'tone' => $s['tone'] ?? null,
'color' => $s['color'] ?? null,
])->all();
};
$activeSeries = collect($active['series'])->map(fn ($s) => ['name' => $s['name'], 'data' => $s['data']])->all();
// Real token colours for both bar series (not a pale tint) — chart
// config data, not markup, so the raw-colour guard does not apply here.
// Resolved the same way chart-metric resolves its accent.
$chartOptions = [
'colors' => [
'var(--chart-1-color, hsl(var(--chart-1)))',
'var(--chart-2-color, hsl(var(--chart-2)))',
],
// A second, custom legend already sits above the chart (chart-legend);
// ApexCharts' own would duplicate it.
'legend' => ['show' => false],
// The headline value/delta already carries the "what changed" story;
// a growth-in entrance has nothing to add on a panel the range select
// can redraw at any moment, so every draw (initial and swapped) is
// instant rather than staggered.
'chart' => ['animations' => ['enabled' => false]],
'xaxis' => ['categories' => $active['categories']],
'yaxis' => [
'min' => 0,
'forceNiceScale' => true,
'tickAmount' => 5,
],
];
// Compact USD ticks (e.g. "$2.5k") on the y-axis and in the tooltip —
// the chart primitive's `format` prop builds one Intl.NumberFormat and
// applies it wherever the options above don't already set a formatter
// (see chart.js). Reads the amount straight off the axis/tooltip instead
// of the axis-title workaround ("Revenue & expense ($)") this block used
// before `format` existed.
$chartFormat = ['style' => 'currency', 'currency' => 'USD', 'notation' => 'compact', 'maximumFractionDigits' => 1];
// Just what chart's public `update()` needs per range (see chart.js's
// "Public: replace the series" note) — reached from this wrapper's scope
// via window.Alpine.$data(). The legend for every range is rendered up
// front below and toggled with x-show, so only the chart itself needs a
// live update when the range select changes.
$rangesJs = collect($ranges)->mapWithKeys(fn ($rangeData, $key) => [
$key => [
'series' => collect($rangeData['series'])->map(fn ($s) => ['name' => $s['name'], 'data' => $s['data']])->all(),
'categories' => $rangeData['categories'],
],
])->all();
$summaryId = 'chart-comparison-summary-'.substr(md5($title.$activeRange), 0, 8);
$chartLabel = __(':title — :s1 vs :s2, :range', [
'title' => $title,
's1' => __('Revenue'),
's2' => __('Expense'),
'range' => $active['label'],
]);
@endphp
{{--
Chart Comparison — a two-series revenue/expense bar chart framed in a
tray, with a large chart-legend headline (square marker + value + delta
per series, matching the chart's solid bars) and a range select that
swaps the whole dataset client-side via chart's public `update()` (no
primitive change needed — see chart.js). The legend for every bundled
range is rendered up front and toggled with x-show, its initial
visibility already matching the active range in the server-rendered
markup so there is no flash before Alpine hydrates.
--}}
<div
data-slot="chart-comparison"
x-data="{
range: @js($activeRange),
ranges: @js($rangesJs),
applyRange(key) {
const data = this.ranges[key];
// Not $refs: an x-ref on an element that carries its own x-data
// (the chart) registers on THAT element's own scope, not this
// wrapper's — plain DOM lookup sidesteps the collision.
const el = this.$el.querySelector('[data-slot=chart]');
if (! data || ! el || ! window.Alpine) return;
const chartData = window.Alpine.$data(el);
if (chartData && typeof chartData.update === 'function') {
chartData.update({ series: data.series, categories: data.categories });
}
},
init() {
this.$watch('range', (value) => this.applyRange(value));
},
}"
{{ $attributes->merge(['class' => 'w-full']) }}
>
<x-ui.tray :title="$title" heading-level="h3" padding="lg">
<x-slot:icon>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18" /><path d="M7 16v-4" /><path d="M12 16V8" /><path d="M17 16v-7" /></svg>
</x-slot:icon>
<div class="flex flex-col gap-6">
{{-- Stacked below sm: a narrow row has no width left to shrink
the legend's value/delta badge into once the select also
claims space on the same line, so the row switches to a
real line break instead of relying on flex-wrap to shrink
its children. --}}
<div class="flex flex-col items-start gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
@foreach ($ranges as $key => $rangeData)
<x-ui.chart-legend
:items="$legendItems($rangeData)"
size="lg"
marker="square"
:label="__(':range legend', ['range' => $rangeData['label']])"
x-show="range === '{{ $key }}'"
:style="$key === $activeRange ? null : 'display:none'"
/>
@endforeach
</div>
<div class="w-full shrink-0 sm:w-auto">
<x-ui.select x-model="range" size="sm" class="w-full sm:w-auto" :aria-label="__('Date range')">
@foreach ($ranges as $key => $rangeData)
<option value="{{ $key }}" @selected($key === $activeRange)>{{ $rangeData['label'] }}</option>
@endforeach
</x-ui.select>
</div>
</div>
<p id="{{ $summaryId }}" class="sr-only">
{{ __(':range: :s1Name :s1Value (:s1Delta%), :s2Name :s2Value (:s2Delta%).', [
'range' => $active['label'],
's1Name' => $active['series'][0]['name'],
's1Value' => $active['series'][0]['value'] ?? '',
's1Delta' => $active['series'][0]['delta'] ?? '',
's2Name' => $active['series'][1]['name'],
's2Value' => $active['series'][1]['value'] ?? '',
's2Delta' => $active['series'][1]['delta'] ?? '',
]) }}
</p>
<x-ui.chart
type="bar"
:series="$activeSeries"
:options="$chartOptions"
:format="$chartFormat"
:height="$height"
:label="$chartLabel"
aria-describedby="{{ $summaryId }}"
/>
</div>
</x-ui.tray>
</div>
Installation
php artisan ui:add blocks/chart-comparison
Registry contract
Install confidence
php artisan ui:add blocks/chart-comparison
writes only the generated targets below. The CLI validates each file hash before writing and
prompts before replacing local changes unless --force is used.
- Version
- 1.0.1
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/chart-comparison.blade.php |
Registry dependencies
Package dependencies
composer: jml/brok:^0.2
npm: alpinejs
npm: apexcharts
Use with AI
A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.
# Brok UI block: Chart Comparison (`chart-comparison`)
A two-series revenue/expense bar chart framed in a tray, led by a large chart-legend headline with a square marker, value and delta per series, compact-USD axis/tooltip ticks, and a range select that swaps the whole dataset client-side.
Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.
## Install
```bash
php artisan ui:add blocks/chart-comparison
```
## Render it
```blade
<x-blocks.chart-comparison />
```
## Props
- `title` (string, default `Total revenue`) — Tray header title.
- `series` (array|null, default `null`) — Two named series for the "Last 30 days" bundled range: each an object with name, data, value (headline string), delta (number), an optional tone override, and an optional color (a chart-N token). Null renders the bundled sample.
- `categories` (array|null, default `null`) — X-axis categories for the "Last 30 days" bundled range. Null renders four sample week labels.
- `ranges` (array|null, default `null`) — The full bundle the range select swaps between client-side: a map of range key to {label, categories, series}. Null renders three bundled samples (7d, 30d, 12m); passing this replaces the whole bundle, including "30d".
- `range` (string|null, default `null`) — The initially selected range key. Null defaults to "30d" (falling back to the bundle's first key when "30d" is absent).
- `height` (int, default `320`) — Chart height in pixels.
## Use when
- Two related measures (revenue and expense, sign-ups and churn) need to be compared across the same categories in one panel.
- A dashboard panel should let the viewer switch between a few bundled time ranges without reloading the page.
## Avoid when
- Only one measure is tracked; use chart-metric instead.
- The two measures live on different scales and need their own axes; use chart-dual-axis instead.
## Rules
- Render the installed block with `<x-blocks.chart-comparison />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.
## Links
- Docs: https://brokui.dev/blocks/chart-comparison
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/chart-comparison.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Guidance
Use when
- Two related measures (revenue and expense, sign-ups and churn) need to be compared across the same categories in one panel.
- A dashboard panel should let the viewer switch between a few bundled time ranges without reloading the page.
Avoid when
- Only one measure is tracked; use chart-metric instead.
- The two measures live on different scales and need their own axes; use chart-dual-axis instead.
Anti-patterns
- Do not replace semantic props with conflicting utility classes.
- Do not remove labels, focus styles, or state attributes.
- Anatomy
Usage
Render the block as a component, passing data where useful:
<x-blocks.chart-comparison />
Built from primitives
This block composes installed <brok:*> primitives and semantic design
tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by
editing CSS variables; flip the preview to RTL to confirm it mirrors.
Source
The exact, editable file ui:add writes
into your app. The preview above renders this same source — there are no preview-only blocks.
@props([
'title' => 'Total revenue',
'ranges' => null,
'range' => null,
'series' => null,
'categories' => null,
'height' => 320,
])
@php
// Two named series for the "Last 30 days" bundled period — shaped for
// both chart-legend (name/value/delta/tone) and the bar chart
// (name/data). `series`/`categories` seed that one period; `ranges`
// below adds the other two the select swaps between client-side, and
// fully replaces this bundle (including "30d") when supplied.
$series ??= [
[
'name' => __('Revenue'),
'data' => [1950, 2180, 2260, 2360],
'value' => '$8,750.00',
'delta' => 12,
'color' => 'chart-1',
],
[
'name' => __('Expense'),
'data' => [430, 460, 470, 492],
'value' => '$1,852.00',
'delta' => -3,
// A fallen expense is good news even though the delta reads
// negative — override the legend badge's tone without flipping
// the down arrow that shows what actually happened.
'tone' => 'positive',
'color' => 'chart-2',
],
];
$categories ??= [__('Week 1'), __('Week 2'), __('Week 3'), __('Week 4')];
// The full bundle the range select switches between. Each entry carries
// its own categories + two series (revenue, expense) in the same shape
// as `series` above.
$ranges ??= [
'7d' => [
'label' => __('Last 7 days'),
'categories' => [__('Mon'), __('Tue'), __('Wed'), __('Thu'), __('Fri'), __('Sat'), __('Sun')],
'series' => [
['name' => __('Revenue'), 'data' => [1150, 1280, 1340, 1460, 1610, 980, 850], 'value' => '$2,120.00', 'delta' => 8, 'color' => 'chart-1'],
['name' => __('Expense'), 'data' => [260, 280, 300, 310, 340, 190, 170], 'value' => '$420.00', 'delta' => -5, 'tone' => 'positive', 'color' => 'chart-2'],
],
],
'30d' => [
'label' => __('Last 30 days'),
'categories' => $categories,
'series' => $series,
],
'12m' => [
'label' => __('Last 12 months'),
'categories' => [__('Jan'), __('Feb'), __('Mar'), __('Apr'), __('May'), __('Jun'), __('Jul'), __('Aug'), __('Sep'), __('Oct'), __('Nov'), __('Dec')],
'series' => [
['name' => __('Revenue'), 'data' => [5200, 5600, 6100, 6700, 7300, 7900, 8400, 8800, 9200, 9600, 10100, 10700], 'value' => '$96,400.00', 'delta' => 18, 'color' => 'chart-1'],
['name' => __('Expense'), 'data' => [1550, 1600, 1650, 1700, 1750, 1780, 1820, 1850, 1880, 1900, 1930, 1960], 'value' => '$21,340.00', 'delta' => -6, 'tone' => 'positive', 'color' => 'chart-2'],
],
],
];
$activeRange = ($range !== null && array_key_exists($range, $ranges))
? $range
: (array_key_exists('30d', $ranges) ? '30d' : array_key_first($ranges));
$active = $ranges[$activeRange];
// Legend rows for one range, in chart-legend's `items` shape.
$legendItems = function (array $rangeData) {
return collect($rangeData['series'])->map(fn ($s) => [
'label' => $s['name'],
'value' => $s['value'] ?? null,
'delta' => $s['delta'] ?? null,
'tone' => $s['tone'] ?? null,
'color' => $s['color'] ?? null,
])->all();
};
$activeSeries = collect($active['series'])->map(fn ($s) => ['name' => $s['name'], 'data' => $s['data']])->all();
// Real token colours for both bar series (not a pale tint) — chart
// config data, not markup, so the raw-colour guard does not apply here.
// Resolved the same way chart-metric resolves its accent.
$chartOptions = [
'colors' => [
'var(--chart-1-color, hsl(var(--chart-1)))',
'var(--chart-2-color, hsl(var(--chart-2)))',
],
// A second, custom legend already sits above the chart (chart-legend);
// ApexCharts' own would duplicate it.
'legend' => ['show' => false],
// The headline value/delta already carries the "what changed" story;
// a growth-in entrance has nothing to add on a panel the range select
// can redraw at any moment, so every draw (initial and swapped) is
// instant rather than staggered.
'chart' => ['animations' => ['enabled' => false]],
'xaxis' => ['categories' => $active['categories']],
'yaxis' => [
'min' => 0,
'forceNiceScale' => true,
'tickAmount' => 5,
],
];
// Compact USD ticks (e.g. "$2.5k") on the y-axis and in the tooltip —
// the chart primitive's `format` prop builds one Intl.NumberFormat and
// applies it wherever the options above don't already set a formatter
// (see chart.js). Reads the amount straight off the axis/tooltip instead
// of the axis-title workaround ("Revenue & expense ($)") this block used
// before `format` existed.
$chartFormat = ['style' => 'currency', 'currency' => 'USD', 'notation' => 'compact', 'maximumFractionDigits' => 1];
// Just what chart's public `update()` needs per range (see chart.js's
// "Public: replace the series" note) — reached from this wrapper's scope
// via window.Alpine.$data(). The legend for every range is rendered up
// front below and toggled with x-show, so only the chart itself needs a
// live update when the range select changes.
$rangesJs = collect($ranges)->mapWithKeys(fn ($rangeData, $key) => [
$key => [
'series' => collect($rangeData['series'])->map(fn ($s) => ['name' => $s['name'], 'data' => $s['data']])->all(),
'categories' => $rangeData['categories'],
],
])->all();
$summaryId = 'chart-comparison-summary-'.substr(md5($title.$activeRange), 0, 8);
$chartLabel = __(':title — :s1 vs :s2, :range', [
'title' => $title,
's1' => __('Revenue'),
's2' => __('Expense'),
'range' => $active['label'],
]);
@endphp
{{--
Chart Comparison — a two-series revenue/expense bar chart framed in a
tray, with a large chart-legend headline (square marker + value + delta
per series, matching the chart's solid bars) and a range select that
swaps the whole dataset client-side via chart's public `update()` (no
primitive change needed — see chart.js). The legend for every bundled
range is rendered up front and toggled with x-show, its initial
visibility already matching the active range in the server-rendered
markup so there is no flash before Alpine hydrates.
--}}
<div
data-slot="chart-comparison"
x-data="{
range: @js($activeRange),
ranges: @js($rangesJs),
applyRange(key) {
const data = this.ranges[key];
// Not $refs: an x-ref on an element that carries its own x-data
// (the chart) registers on THAT element's own scope, not this
// wrapper's — plain DOM lookup sidesteps the collision.
const el = this.$el.querySelector('[data-slot=chart]');
if (! data || ! el || ! window.Alpine) return;
const chartData = window.Alpine.$data(el);
if (chartData && typeof chartData.update === 'function') {
chartData.update({ series: data.series, categories: data.categories });
}
},
init() {
this.$watch('range', (value) => this.applyRange(value));
},
}"
{{ $attributes->merge(['class' => 'w-full']) }}
>
<x-ui.tray :title="$title" heading-level="h3" padding="lg">
<x-slot:icon>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18" /><path d="M7 16v-4" /><path d="M12 16V8" /><path d="M17 16v-7" /></svg>
</x-slot:icon>
<div class="flex flex-col gap-6">
{{-- Stacked below sm: a narrow row has no width left to shrink
the legend's value/delta badge into once the select also
claims space on the same line, so the row switches to a
real line break instead of relying on flex-wrap to shrink
its children. --}}
<div class="flex flex-col items-start gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
@foreach ($ranges as $key => $rangeData)
<x-ui.chart-legend
:items="$legendItems($rangeData)"
size="lg"
marker="square"
:label="__(':range legend', ['range' => $rangeData['label']])"
x-show="range === '{{ $key }}'"
:style="$key === $activeRange ? null : 'display:none'"
/>
@endforeach
</div>
<div class="w-full shrink-0 sm:w-auto">
<x-ui.select x-model="range" size="sm" class="w-full sm:w-auto" :aria-label="__('Date range')">
@foreach ($ranges as $key => $rangeData)
<option value="{{ $key }}" @selected($key === $activeRange)>{{ $rangeData['label'] }}</option>
@endforeach
</x-ui.select>
</div>
</div>
<p id="{{ $summaryId }}" class="sr-only">
{{ __(':range: :s1Name :s1Value (:s1Delta%), :s2Name :s2Value (:s2Delta%).', [
'range' => $active['label'],
's1Name' => $active['series'][0]['name'],
's1Value' => $active['series'][0]['value'] ?? '',
's1Delta' => $active['series'][0]['delta'] ?? '',
's2Name' => $active['series'][1]['name'],
's2Value' => $active['series'][1]['value'] ?? '',
's2Delta' => $active['series'][1]['delta'] ?? '',
]) }}
</p>
<x-ui.chart
type="bar"
:series="$activeSeries"
:options="$chartOptions"
:format="$chartFormat"
:height="$height"
:label="$chartLabel"
aria-describedby="{{ $summaryId }}"
/>
</div>
</x-ui.tray>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0