Skip to content
Brok UI

Loading…

No results

Chart Blocks

Open source

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

5 of 5

resources/views/blocks/chart-comparison.blade.php Blade
@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

terminal
php artisan ui:add blocks/chart-comparison

Installs these dependencies automatically:

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

tray chart-legend select chart

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.

chart-comparison.md
# 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
tray tray-header tray-icon tray-title tray-well chart-legend (per range, toggled) range select grouped bar chart

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.chart-comparison />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

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.

resources/views/blocks/chart-comparison.blade.php Blade
@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