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.8.3
Stability
stable
License
MIT
Related
Chart Legend
Chart Tooltip
Heatmap
1 of 7

Chart — A token-themed chart wrapper around ApexCharts (area, line, bar, scatter, pie, radar, radial, treemap, boxplot, range & mixed) with themed grid, legend and tooltip.

Preview

This wrapper requires ApexCharts. Run `npm install apexcharts` and make it available on `window` to render charts; until then a loading state is shown.

previews.components.chart.default.blade.php Blade
<div class="space-y-4">
    <x-ui.chart
        type="bar"
        :height="300"
        :series="[
            ['name' => __('Revenue'), 'data' => [30, 40, 35, 50, 49, 60, 70]],
            ['name' => __('Costs'), 'data' => [18, 22, 24, 28, 27, 31, 36]],
        ]"
        :options="['xaxis' => ['categories' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']]]"
        label="{{ __('Monthly revenue against costs') }}"
    />

    <p class="text-sm text-muted-foreground">
        {{ __('This wrapper requires ApexCharts. Run `npm install apexcharts` and make it available on `window` to render charts; until then a loading state is shown.') }}
    </p>
</div>

Installation

terminal
php artisan ui:add chart

Note

This component ships an Alpine behavior module at resources/js/ui/chart.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './chart.js';

Registry contract

php artisan ui:add chart 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.blade.php
  • js resources/js/ui/chart.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs
npm: apexcharts

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.md
# Brok UI: Chart (`chart`)

A token-themed chart wrapper around ApexCharts (area, line, bar, scatter, pie, radar, radial, treemap, boxplot, range & mixed) with themed grid, legend and tooltip.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add chart
```

## Usage

```blade
<div class="space-y-4">
    <x-ui.chart
        type="bar"
        :height="300"
        :series="[
            ['name' => __('Revenue'), 'data' => [30, 40, 35, 50, 49, 60, 70]],
            ['name' => __('Costs'), 'data' => [18, 22, 24, 28, 27, 31, 36]],
        ]"
        :options="['xaxis' => ['categories' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']]]"
        label="{{ __('Monthly revenue against costs') }}"
    />

    <p class="text-sm text-muted-foreground">
        {{ __('This wrapper requires ApexCharts. Run `npm install apexcharts` and make it available on `window` to render charts; until then a loading state is shown.') }}
    </p>
</div>
```

## Props

- `type` (string, default `bar`) — ApexCharts chart family: area, line, bar, scatter, pie, radar, radial, treemap, boxplot, range, or mixed, among others.
- `series` (array, default `[]`) — The chart's data series, in ApexCharts' series format for the chosen type. An axis series may carry `tone` (a token colour, see tones) or a literal `color`; `color` wins.
- `tones` (array, default `[]`) — One tone per slice or series, for charts whose series is a list of numbers (pie, donut, polarArea, radialBar); an axis series may carry its own `tone`, which wins. Tones: `primary|foreground|muted|success|warning|destructive|info`, each resolved from its design token (muted = muted-foreground) and again on a theme flip; an unknown tone throws. Untoned series keep the --chart-1..5 palette.
- `options` (array, default `[]`) — Raw ApexCharts options merged into the themed defaults, for anything not covered by type/series/height/label.
- `height` (int, default `320`) — Minimum render height in pixels; also used as the loading placeholder's height.
- `label` (string, default `Chart`) — Accessible name for the figure.
- `format` (string|array|null, default `null`) — Formats value-axis labels, tooltip values and data labels without an ApexCharts formatter function, which cannot cross the @js() JSON boundary. A preset string (currency, percent, compact, number) or an array of Intl.NumberFormat options, e.g. ['style' => 'currency', 'currency' => 'USD', 'notation' => 'compact', 'maximumFractionDigits' => 1] for ticks like "$2.5k". Applies to the y-axis, or the x-axis on a horizontal bar chart. Null leaves every value unformatted (ApexCharts' own raw numbers), and any formatter the caller's own `options` already sets always wins.

## Use when

- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Plotting real series data (area, line, bar, scatter, pie, radar, radial, treemap, boxplot, range or mixed) with a themed grid, legend and tooltip.
- Needing a chart that re-themes itself automatically when the app switches between light and dark.

## 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.
- Choose a chart only when the user’s question is inherently visual, such as trend, distribution, flow, or relationship.
- The visual is a static illustrative diagram (flow, org, mind map, network) rather than plotted series data; use the matching diagram component instead.
- Only a single value or percentage needs to be shown; use gauge, circular-progress or usage-meter instead.

## Anti-patterns

- Decorative charts without a meaningful question

## Rules

- Use the `<brok:chart>` tag (or `<x-ui.chart>`) 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
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chart.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

area.blade.php Blade
<x-ui.chart
    type="area"
    :height="300"
    :series="[
        ['name' => __('Sessions'), 'data' => [310, 420, 380, 510, 490, 620, 700, 660, 740]],
        ['name' => __('Sign-ups'), 'data' => [120, 160, 150, 210, 190, 260, 300, 280, 330]],
    ]"
    :options="['xaxis' => ['categories' => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon', 'Tue']]]"
    label="{{ __('Sessions and sign-ups, last nine days') }}"
/>
bar.blade.php Blade
{{-- Grouped columns with end-rounded corners, framed by chart-card. --}}
<x-ui.chart-card heading="Visitors" subheading="Desktop vs. mobile sessions" trend="+8.2%">
    <x-ui.chart
        type="bar"
        :height="300"
        :series="[
            ['name' => __('Desktop'), 'data' => [44, 55, 41, 67, 22, 43, 36]],
            ['name' => __('Mobile'), 'data' => [13, 23, 20, 8, 13, 27, 33]],
        ]"
        :options="[
            'plotOptions' => ['bar' => ['borderRadius' => 6, 'borderRadiusApplication' => 'end', 'columnWidth' => '55%']],
            'stroke' => ['show' => true, 'width' => 3, 'colors' => ['transparent']],
            'legend' => ['position' => 'bottom'],
            'xaxis' => ['categories' => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']],
        ]"
        label="{{ __('Desktop and mobile sessions per weekday') }}"
    />
</x-ui.chart-card>
bar-horizontal.blade.php Blade
{{-- Horizontal bars with end-rounded corners rank items top to bottom. --}}
<x-ui.chart-card heading="Top products" subheading="Revenue by product ($k)" trend="+9.8%">
    <x-ui.chart
        type="bar"
        :height="300"
        :series="[['name' => __('Revenue'), 'data' => [186, 152, 131, 108, 84, 57]]]"
        :options="[
            'plotOptions' => ['bar' => ['horizontal' => true, 'borderRadius' => 6, 'borderRadiusApplication' => 'end', 'columnWidth' => '60%']],
            'dataLabels' => ['enabled' => false],
            'legend' => ['show' => false],
            'xaxis' => ['categories' => [__('Starter Plan'), __('Pro Plan'), __('Team Seats'), __('Add-ons'), __('Enterprise'), __('Support')]],
        ]"
        label="{{ __('Revenue by product') }}"
    />
</x-ui.chart-card>
boxplot.blade.php Blade
{{-- Each box carries [min, q1, median, q3, max] under a category label. --}}
<x-ui.chart-card heading="Latency by region" subheading="Request latency distribution (ms)" trend="−6ms">
    <x-ui.chart
        type="boxPlot"
        :height="300"
        :series="[['type' => 'boxPlot', 'data' => [
            ['x' => __('US-East'), 'y' => [42, 68, 92, 128, 210]],
            ['x' => __('US-West'), 'y' => [55, 84, 116, 152, 248]],
            ['x' => __('EU'), 'y' => [61, 96, 134, 178, 286]],
            ['x' => __('APAC'), 'y' => [88, 132, 184, 244, 372]],
        ]]]"
        :options="['xaxis' => ['type' => 'category']]"
        label="{{ __('Request latency spread by region') }}"
    />
</x-ui.chart-card>
bubble.blade.php Blade
<x-ui.chart
    type="bubble"
    :height="300"
    :series="[
        ['name' => __('EMEA'), 'data' => [[12, 38, 18], [26, 52, 30], [44, 61, 12], [58, 47, 22], [72, 70, 40]]],
        ['name' => __('Americas'), 'data' => [[8, 20, 10], [30, 34, 26], [50, 28, 16], [66, 55, 34], [84, 42, 20]]],
        ['name' => __('APAC'), 'data' => [[18, 66, 14], [36, 74, 24], [54, 82, 30], [70, 60, 12], [90, 78, 28]]],
    ]"
    :options="[
        'xaxis' => ['title' => ['text' => __('Reach')]],
        'yaxis' => ['title' => ['text' => __('Engagement')]],
    ]"
    label="{{ __('Campaign reach against engagement; bubble size is spend') }}"
/>
candlestick.blade.php Blade
@php
    $data = [];
    $close = 104.0;
    $t = strtotime('2026-06-01');
    for ($i = 0; $i < 30; $i++) {
        $open = $close;
        $swing = ((($i * 37) % 11) - 5) * 0.6;
        $close = round($open + $swing, 2);
        $high = round(max($open, $close) + (($i * 13) % 4) * 0.4 + 0.3, 2);
        $low = round(min($open, $close) - (($i * 17) % 4) * 0.4 - 0.3, 2);
        $data[] = ['x' => ($t + $i * 86400) * 1000, 'y' => [$open, $high, $low, $close]];
    }
@endphp
<x-ui.chart
    type="candlestick"
    :height="300"
    :series="[['name' => __('BROK'), 'data' => $data]]"
    label="{{ __('Thirty days of daily open, high, low and close') }}"
/>
donut.blade.php Blade
<div class="grid gap-6 sm:grid-cols-2">
    <x-ui.chart
        type="donut"
        :height="280"
        :series="[44, 31, 15, 10]"
        :options="['labels' => [__('Organic'), __('Paid'), __('Referral'), __('Direct')]]"
        label="{{ __('Traffic by source') }}"
    />
    <x-ui.chart
        type="pie"
        :height="280"
        :series="[52, 28, 20]"
        :options="['labels' => [__('Starter'), __('Pro'), __('Enterprise')]]"
        label="{{ __('Customers by plan') }}"
    />
</div>
format.blade.php Blade
{{-- `format` formats the y-axis and tooltip without an ApexCharts formatter
     function (those cannot cross the @js() JSON boundary) — ticks read
     "$2.5k" instead of raw numbers. --}}
<x-ui.chart
    type="bar"
    :height="300"
    :series="[['name' => __('Revenue'), 'data' => [1950, 2180, 2260, 2360, 2510, 2780, 2950]]]"
    :options="['xaxis' => ['categories' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']]]"
    :format="['style' => 'currency', 'currency' => 'USD', 'notation' => 'compact', 'maximumFractionDigits' => 1]"
    label="{{ __('Monthly revenue') }}"
/>
funnel.blade.php Blade
{{-- A horizontal funnel-shaped bar with inline value labels. --}}
<x-ui.chart-card heading="Conversion funnel" subheading="Last 30 days" trend="5.1% paid" trend-variant="neutral">
    <x-ui.chart
        type="bar"
        :height="300"
        :series="[['name' => __('Users'), 'data' => [12480, 5230, 2110, 640]]]"
        :options="[
            'plotOptions' => ['bar' => ['horizontal' => true, 'borderRadius' => 2, 'isFunnel' => true]],
            'dataLabels' => ['enabled' => true],
            'legend' => ['show' => false],
            'xaxis' => ['categories' => [__('Visited'), __('Signed up'), __('Activated'), __('Paid')]],
        ]"
        label="{{ __('Conversion from visit to paid') }}"
    />
</x-ui.chart-card>
heatmap.blade.php Blade
@php
    // Hour-of-day × weekday matrix (deterministic so the preview is stable).
    $days = [__('Mon'), __('Tue'), __('Wed'), __('Thu'), __('Fri'), __('Sat'), __('Sun')];
    $hours = ['00', '03', '06', '09', '12', '15', '18', '21'];
    $series = [];
    foreach ($days as $d => $day) {
        $data = [];
        foreach ($hours as $h => $hour) {
            $peak = $d < 5 ? 1 : 0.45;
            $curve = max(0, 1 - abs($h - 4) / 3.2);
            $data[] = ['x' => $hour, 'y' => (int) round(8 + 92 * $curve * $peak * (1 - (($d * 7 + $h * 3) % 5) / 25))];
        }
        $series[] = ['name' => $day, 'data' => $data];
    }
@endphp
<x-ui.chart
    type="heatmap"
    :height="300"
    :series="$series"
    label="{{ __('Activity by weekday and hour') }}"
/>
histogram.blade.php Blade
{{-- Wide, gapless columns with a hairline transparent stroke read as a histogram. --}}
<x-ui.chart-card heading="Response time distribution" subheading="Requests by latency bucket (ms)" trend="p95 280ms" trend-variant="neutral">
    <x-ui.chart
        type="bar"
        :height="300"
        :series="[['name' => __('Requests'), 'data' => [420, 1180, 2240, 1560, 720, 310, 140]]]"
        :options="[
            'plotOptions' => ['bar' => ['columnWidth' => '98%', 'borderRadius' => 0]],
            'stroke' => ['show' => true, 'width' => 1, 'colors' => ['transparent']],
            'legend' => ['show' => false],
            'xaxis' => ['categories' => ['0–50', '50–100', '100–150', '150–200', '200–250', '250–300', '300+']],
        ]"
        label="{{ __('Requests per latency bucket') }}"
    />
</x-ui.chart-card>
legend-tooltip.blade.php Blade
{{-- The three pieces of chart chrome restyled: a top-aligned legend, a shared
     tooltip that follows the cursor, and striped plot rows from the muted token. --}}
<x-ui.chart-card heading="Sessions" subheading="Top-aligned legend, shared tooltip, striped plot background" trend="+9.7%">
    <x-ui.chart
        type="area"
        :height="300"
        :series="[
            ['name' => __('Desktop'), 'data' => [31, 40, 28, 51, 42, 62, 70, 58]],
            ['name' => __('Mobile'), 'data' => [11, 32, 45, 32, 34, 52, 41, 60]],
        ]"
        :options="[
            'stroke' => ['curve' => 'smooth', 'width' => 2],
            'fill' => ['type' => 'gradient', 'gradient' => ['opacityFrom' => 0.35, 'opacityTo' => 0.05, 'stops' => [0, 90, 100]]],
            'legend' => ['position' => 'top', 'horizontalAlign' => 'left', 'markers' => ['size' => 6, 'offsetX' => -2], 'itemMargin' => ['horizontal' => 12, 'vertical' => 4]],
            'tooltip' => ['shared' => true, 'intersect' => false, 'followCursor' => true],
            'grid' => ['row' => ['colors' => ['var(--muted-color, hsl(var(--muted)))', 'transparent'], 'opacity' => 0.4]],
            'xaxis' => ['categories' => ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W8']],
        ]"
        label="{{ __('Sessions per week by device') }}"
    />
</x-ui.chart-card>
line.blade.php Blade
<x-ui.chart
    type="line"
    :height="300"
    :series="[
        ['name' => __('p50'), 'data' => [120, 118, 125, 110, 108, 112, 105]],
        ['name' => __('p95'), 'data' => [340, 360, 380, 330, 310, 320, 300]],
        ['name' => __('p99'), 'data' => [620, 700, 660, 580, 540, 560, 520]],
    ]"
    :options="['xaxis' => ['categories' => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']]]"
    label="{{ __('Request latency percentiles') }}"
/>
mixed.blade.php Blade
<x-ui.chart
    type="line"
    :height="300"
    :series="[
        ['name' => __('Orders'), 'type' => 'column', 'data' => [440, 505, 414, 671, 227, 413, 201, 352]],
        ['name' => __('Revenue'), 'type' => 'area', 'data' => [23, 42, 35, 27, 43, 22, 17, 31]],
        ['name' => __('Conversion'), 'type' => 'line', 'data' => [3.0, 3.6, 3.2, 4.1, 2.6, 3.3, 2.4, 3.1]],
    ]"
    :options="[
        'xaxis' => ['categories' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug']],
        'yaxis' => [
            ['seriesName' => __('Orders'), 'title' => ['text' => __('Orders')]],
            ['seriesName' => __('Revenue'), 'show' => false],
            ['seriesName' => __('Conversion'), 'opposite' => true, 'title' => ['text' => __('Conversion %')]],
        ],
    ]"
    label="{{ __('Orders, revenue and conversion per month') }}"
/>
pie.blade.php Blade
{{-- A pie series is a flat list of numbers; segment names live in `labels`. --}}
<x-ui.chart-card heading="Traffic sources" subheading="Share of sessions this month">
    <x-ui.chart
        type="pie"
        :height="320"
        :series="[44, 32, 18, 6]"
        :options="[
            'labels' => [__('Direct'), __('Organic'), __('Referral'), __('Social')],
            'stroke' => ['width' => 0],
            'dataLabels' => ['enabled' => true],
            'legend' => ['position' => 'bottom'],
        ]"
        label="{{ __('Share of sessions by traffic source') }}"
    />
</x-ui.chart-card>
polar.blade.php Blade
<div class="grid gap-6 sm:grid-cols-2">
    <x-ui.chart
        type="polarArea"
        :height="300"
        :series="[42, 47, 52, 58, 33, 27]"
        :options="['labels' => [__('Speed'), __('Reliability'), __('Comfort'), __('Safety'), __('Efficiency'), __('Design')]]"
        label="{{ __('Scores per attribute') }}"
    />
    <x-ui.chart
        type="radar"
        :height="300"
        :series="[
            ['name' => __('This quarter'), 'data' => [80, 65, 72, 90, 60, 75]],
            ['name' => __('Last quarter'), 'data' => [62, 70, 58, 74, 55, 68]],
        ]"
        :options="['xaxis' => ['categories' => [__('Speed'), __('Reliability'), __('Comfort'), __('Safety'), __('Efficiency'), __('Design')]]]"
        label="{{ __('Attribute scores, this quarter against last') }}"
    />
</div>
radar.blade.php Blade
{{-- Two profiles over six axes; the polygon grid takes the border token. --}}
<x-ui.chart-card heading="Performance profile" subheading="Current release vs. previous">
    <x-ui.chart
        type="radar"
        :height="320"
        :series="[
            ['name' => __('Current'), 'data' => [80, 90, 70, 85, 65, 75]],
            ['name' => __('Previous'), 'data' => [70, 75, 60, 70, 55, 68]],
        ]"
        :options="[
            'stroke' => ['width' => 2],
            'fill' => ['opacity' => 0.15],
            'markers' => ['size' => 3],
            'legend' => ['position' => 'bottom'],
            'xaxis' => ['categories' => [__('Speed'), __('Reliability'), __('Comfort'), __('Safety'), __('Efficiency'), __('Design')]],
            'plotOptions' => ['radar' => ['polygons' => [
                'strokeColors' => 'var(--border-color, hsl(var(--border)))',
                'connectorColors' => 'var(--border-color, hsl(var(--border)))',
            ]]],
        ]"
        label="{{ __('Release performance profile') }}"
    />
</x-ui.chart-card>
radial.blade.php Blade
<div class="grid gap-6 sm:grid-cols-2">
    <x-ui.chart
        type="radialBar"
        :height="280"
        :series="[68]"
        :options="['labels' => [__('Storage used')]]"
        label="{{ __('Storage used: 68%') }}"
    />
    <x-ui.chart
        type="radialBar"
        :height="280"
        :series="[82, 64, 47]"
        :options="[
            'labels' => [__('CPU'), __('Memory'), __('Disk')],
            'plotOptions' => ['radialBar' => ['dataLabels' => ['total' => ['show' => true, 'label' => __('Average')]]]],
        ]"
        label="{{ __('Resource usage: CPU 82%, memory 64%, disk 47%') }}"
    />
</div>
scatter.blade.php Blade
{{-- Scatter series carry [x, y] pairs on a numeric axis; a neutral badge
     holds a statistic rather than a delta. --}}
<x-ui.chart-card heading="Spend vs. engagement" subheading="Each point is one campaign" trend="r = 0.74" trend-variant="neutral">
    <x-ui.chart
        type="scatter"
        :height="300"
        :series="[
            ['name' => __('Paid'), 'data' => [[6, 22], [9, 31], [12, 28], [15, 44], [18, 49], [21, 58], [24, 61], [27, 72]]],
            ['name' => __('Organic'), 'data' => [[4, 14], [7, 18], [10, 21], [13, 26], [16, 30], [19, 33], [22, 41], [25, 46]]],
        ]"
        :options="[
            'legend' => ['position' => 'bottom'],
            'xaxis' => ['title' => ['text' => __('Spend ($k)')], 'tickAmount' => 7],
            'yaxis' => ['title' => ['text' => __('Engagement (%)')]],
        ]"
        label="{{ __('Campaign spend against engagement') }}"
    />
</x-ui.chart-card>
tones.blade.php Blade
{{-- Per-series tones: status colours from design tokens instead of literal colours, so the chart follows the theme and dark mode. --}}
<div class="grid gap-6 lg:grid-cols-2">
    <x-ui.chart-card heading="Issues closed" subheading="Per week, by outcome">
        <x-ui.chart
            type="bar"
            :height="280"
            :series="[
                ['name' => __('Completed'), 'tone' => 'success', 'data' => [18, 22, 17, 25, 21, 28]],
                ['name' => __('Canceled'), 'tone' => 'muted', 'data' => [3, 5, 2, 4, 6, 3]],
                ['name' => __('Reopened'), 'tone' => 'warning', 'data' => [1, 2, 1, 3, 1, 2]],
            ]"
            :options="[
                'chart' => ['stacked' => true],
                'legend' => ['position' => 'bottom'],
                'xaxis' => ['categories' => ['W31', 'W32', 'W33', 'W34', 'W35', 'W36']],
            ]"
            label="{{ __('Issues closed per week: completed, canceled and reopened') }}"
        />
    </x-ui.chart-card>
    <x-ui.chart-card heading="Open issues" subheading="By priority">
        <x-ui.chart
            type="donut"
            :height="280"
            :series="[4, 11, 23, 9]"
            :tones="['destructive', 'warning', 'foreground', 'muted']"
            :options="['labels' => [__('Urgent'), __('High'), __('Medium'), __('Low')]]"
            label="{{ __('Open issues by priority') }}"
        />
    </x-ui.chart-card>
</div>
treemap.blade.php Blade
{{-- Tiles are labelled inline; the legend is meaningless for one treemap series. --}}
<x-ui.chart-card heading="Storage by bucket" subheading="Object storage, this month" trend="+4.2%">
    <x-ui.chart
        type="treemap"
        :height="300"
        :series="[['data' => [
            ['x' => __('Images'), 'y' => 4820],
            ['x' => __('Video'), 'y' => 3110],
            ['x' => __('Backups'), 'y' => 2240],
            ['x' => __('Documents'), 'y' => 1380],
            ['x' => __('Logs'), 'y' => 760],
            ['x' => __('Other'), 'y' => 420],
        ]]]"
        :options="[
            'legend' => ['show' => false],
            'dataLabels' => ['enabled' => true, 'style' => ['fontFamily' => 'inherit']],
        ]"
        label="{{ __('Storage usage by bucket') }}"
    />
</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
type string bar ApexCharts chart family: area, line, bar, scatter, pie, radar, radial, treemap, boxplot, range, or mixed, among others.
series array [] The chart's data series, in ApexCharts' series format for the chosen type. An axis series may carry `tone` (a token colour, see tones) or a literal `color`; `color` wins.
tones array [] One tone per slice or series, for charts whose series is a list of numbers (pie, donut, polarArea, radialBar); an axis series may carry its own `tone`, which wins. Tones: `primary|foreground|muted|success|warning|destructive|info`, each resolved from its design token (muted = muted-foreground) and again on a theme flip; an unknown tone throws. Untoned series keep the --chart-1..5 palette.
options array [] Raw ApexCharts options merged into the themed defaults, for anything not covered by type/series/height/label.
height int 320 Minimum render height in pixels; also used as the loading placeholder's height.
label string Chart Accessible name for the figure.
format string | array | null null Formats value-axis labels, tooltip values and data labels without an ApexCharts formatter function, which cannot cross the @js() JSON boundary. A preset string (currency, percent, compact, number) or an array of Intl.NumberFormat options, e.g. ['style' => 'currency', 'currency' => 'USD', 'notation' => 'compact', 'maximumFractionDigits' => 1] for ticks like "$2.5k". Applies to the y-axis, or the x-axis on a horizontal bar chart. Null leaves every value unformatted (ApexCharts' own raw numbers), and any formatter the caller's own `options` already sets always wins.

Slots

  • default — Unused; the chart renders from the type/series/options props only.

Data slots

Stable hooks for CSS overrides and browser tests.

chart

Behavior

  • Themes palette, grid, axis labels, legend and tooltip from design tokens at render time and re-themes when the `.dark` class flips on <html>.
  • A series `tone` (or the `tones` list for number-list series) colours that series from a design token (primary, foreground, muted, success, warning, destructive, info) instead of a literal colour; untoned series keep the categorical palette and `update({ series, categories, tones })` accepts new tones.
  • A surrounding Alpine scope can redraw it in place: `window.Alpine.$data(el).update({ series, categories })` replaces the series and x-axis categories without re-rendering the Blade, loading any chart family a new per-series `type` introduces first.
  • Shows a loading placeholder overlay until ApexCharts has rendered, so the page never throws while the library loads.
  • `format` builds one Intl.NumberFormat (the document's language, falling back to "en") and applies it to the value axis (y-axis, or x-axis when `options.plotOptions.bar.horizontal` is true), the tooltip, and data labels — skipping any of those the caller's `options` already formats, and leaving every existing radial/percent formatter (radialBar's own dataLabels formatters) untouched either way.
  • Loads ApexCharts, applies token-aware defaults, renders into the referenced container, and shows a fallback while loading or on error.
  • 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.
  • Plotting real series data (area, line, bar, scatter, pie, radar, radial, treemap, boxplot, range or mixed) with a themed grid, legend and tooltip.
  • Needing a chart that re-themes itself automatically when the app switches between light and dark.

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.
  • Choose a chart only when the user’s question is inherently visual, such as trend, distribution, flow, or relationship.
  • The visual is a static illustrative diagram (flow, org, mind map, network) rather than plotted series data; use the matching diagram component instead.
  • Only a single value or percentage needs to be shown; use gauge, circular-progress or usage-meter instead.

Use instead

  • Table for exact lookup
  • Stat for a single value

Anti-patterns

  • Decorative charts without a meaningful question
Anatomy
root
Theming hooks
chart

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root is a role="group" labelled from label, and the plot uses ApexCharts' accessibility layer with that label as its description: legend items are role="button" controls in the Tab order that toggle a series with Enter or Space and report it with aria-pressed, arrow keys move between data points, and changes are announced. Pair a decision-critical chart with a visible summary or table.
  • Series colours come from the design-token palette, never raw hex values, keeping contrast consistent across light and dark.
  • 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-{{ $record->id }}">
    <div class="space-y-4">
        <x-ui.chart
            type="bar"
            :height="300"
            :series="[
                ['name' => __('Revenue'), 'data' => [30, 40, 35, 50, 49, 60, 70]],
                ['name' => __('Costs'), 'data' => [18, 22, 24, 28, 27, 31, 36]],
            ]"
            :options="['xaxis' => ['categories' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul']]]"
            label="{{ __('Monthly revenue against costs') }}"
        />
    
        <p class="text-sm text-muted-foreground">
            {{ __('This wrapper requires ApexCharts. Run `npm install apexcharts` and make it available on `window` to render charts; until then a loading state is shown.') }}
        </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.blade.php Blade
{{--
    Chart: a token-themed ApexCharts wrapper.

    - series: ApexCharts series. An axis series may carry `tone` (see below) or
      a literal `color`; a literal `color` wins over `tone`.
    - tones: one tone per slice or series, for charts whose series is a list of
      numbers (pie, donut, polarArea, radialBar). A series' own `tone` wins.
    - Tones resolve to design tokens at render time and again when the theme
      flips: primary, foreground, muted (muted-foreground), success, warning,
      destructive, info. A series without a tone takes the categorical
      --chart-1..5 palette. An unknown tone throws.
--}}
@props([
    'type' => 'bar',
    'series' => [],
    'tones' => [],
    'options' => [],
    'height' => 320,
    'label' => 'Chart',
    'format' => null,
])
@php
    $toneTokens = [
        'primary' => '--primary',
        'foreground' => '--foreground',
        'muted' => '--muted-foreground',
        'success' => '--success',
        'warning' => '--warning',
        'destructive' => '--destructive',
        'info' => '--info',
    ];
    $requestedTones = array_merge(
        array_values((array) $tones),
        array_map(fn ($entry) => is_array($entry) ? ($entry['tone'] ?? null) : null, array_values((array) $series)),
    );
    foreach ($requestedTones as $requestedTone) {
        if ($requestedTone !== null && ! array_key_exists($requestedTone, $toneTokens)) {
            throw new \InvalidArgumentException("Unknown chart tone [{$requestedTone}]. Use one of: ".implode(', ', array_keys($toneTokens)).'.');
        }
    }
@endphp
<div
    x-data="uiChart({ type: @js($type), series: @js($series), tones: @js(array_values((array) $tones)), toneTokens: @js($toneTokens), options: @js($options), height: {{ (int) $height }}, format: @js($format) })"
    data-slot="chart"
    role="group"
    aria-label="{{ $label }}"
    {{ $attributes->merge(['class' => 'relative w-full']) }}
>
    {{-- Render target for ApexCharts. Kept in layout at full width (NOT toggled
         with x-show) so ApexCharts measures a real width when it renders —
         rendering into a display:none element yields a 0-width blank chart. --}}
    <div x-ref="container" class="w-full" style="min-height: {{ (int) $height }}px"></div>

    {{-- Fallback overlay shown until ApexCharts has rendered (so the page never throws). --}}
    <div
        x-show="!ready"
        class="absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-border bg-muted/30 text-sm text-muted-foreground"
    >
        <span x-text="error || @js(__('Loading chart…'))"></span>
    </div>
</div>
resources/js/ui/chart.js JS
/**
 * Chart behavior (interactive ApexCharts wrapper).
 *
 * Themes the chart off the app's CSS design tokens (read at init): the palette,
 * grid, axis labels, legend and tooltip all track the active theme and flip
 * automatically in dark mode. Supports area / line / bar / scatter / pie / donut /
 * radar / radialBar and mixed (composed) charts via the `type` prop plus
 * per-series `type` keys. Respects prefers-reduced-motion and destroys the chart instance
 * on teardown. Also supports treemap, boxPlot, rangeBar, rangeArea, heatmap,
 * candlestick, bubble and polarArea (the basis for histogram, funnel, waterfall
 * and dual-axes compositions driven by `options`).
 *
 * Lazy-loads ApexCharts so pages without charts do not pay the chart code.
 * Uses ApexCharts' modular v5 entry points and loads only the core, the feature
 * modules this wrapper themes, and the families a given chart actually draws.
 * Importing all eleven families up front cost 178.6 KB gz, 19.6 KB MORE than
 * the monolithic build the modular entry points exist to avoid; a line chart
 * needs 111.8 KB.
 *
 * Caller `options` are deep-merged ON TOP of the themed defaults, so a block can
 * tweak a single sub-key (e.g. `legend.position`) without losing the token
 * theming applied to its siblings.
 *
 * 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.
 */
/*
 * ApexCharts v5 splits the library into a core, one module per chart family and
 * separate feature modules. A family or feature that is never imported is not
 * merely absent from the bundle, it is absent from the runtime: the core still
 * reserves the legend box but draws nothing into it, which is what makes an
 * unpatched modular build render an empty legend.
 *
 * Each family module registers every chart type it renders (`line` also
 * registers area, scatter, bubble and rangeArea; `bar` also column and
 * rangeBar; `pie` also donut and polarArea; `boxPlot` also candlestick), and
 * the per-type entry points Apex publishes for the siblings are byte-identical
 * copies. Importing them by type name would emit one duplicate chunk per
 * sibling and download the same code twice when a page mixes, say, a line and
 * an area chart. Every type therefore maps to its family's one module.
 */
const loadLineFamily = () => import('apexcharts/line');
const loadBarFamily = () => import('apexcharts/bar');
const loadPieFamily = () => import('apexcharts/pie');
const loadBoxPlotFamily = () => import('apexcharts/boxPlot');

const CHART_FAMILIES = {
    line: loadLineFamily,
    area: loadLineFamily,
    scatter: loadLineFamily,
    bubble: loadLineFamily,
    rangeArea: loadLineFamily,
    bar: loadBarFamily,
    column: loadBarFamily,
    rangeBar: loadBarFamily,
    pie: loadPieFamily,
    donut: loadPieFamily,
    polarArea: loadPieFamily,
    radar: () => import('apexcharts/radar'),
    radialBar: () => import('apexcharts/radialBar'),
    treemap: () => import('apexcharts/treemap'),
    boxPlot: loadBoxPlotFamily,
    candlestick: loadBoxPlotFamily,
    heatmap: () => import('apexcharts/heatmap'),
};

// Module scope, not component scope: several charts on one page share the core
// download, and each family is fetched at most once no matter how many charts
// ask for it.
let corePromise = null;
const familyPromises = new Map();

function loadCore() {
    if (!corePromise) {
        corePromise = Promise.all([
            import('apexcharts/core'),
            // The wrapper themes `legend` in baseOptions(). Without this module
            // that theming has nothing to apply to and the legend is empty.
            import('apexcharts/features/legend'),
        ]).then(([core]) => core.default ?? core);
    }

    return corePromise;
}

function loadFamilies(types) {
    return Promise.all(
        types.map((type) => {
            const load = CHART_FAMILIES[type];

            if (!load) {
                return null;
            }

            if (!familyPromises.has(load)) {
                familyPromises.set(load, load());
            }

            return familyPromises.get(load);
        }),
    );
}

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiChart', (config = {}) => ({
        type: config.type ?? 'bar',
        series: config.series ?? [],
        tones: config.tones ?? [],
        toneTokens: config.toneTokens ?? {},
        options: config.options ?? {},
        height: config.height ?? 320,
        // A string preset ("currency" | "percent" | "compact" | "number") or
        // an Intl.NumberFormat options object/array (e.g. { style: 'currency',
        // currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }).
        // Lets a Blade caller format axis/tooltip/data-label values without an
        // ApexCharts formatter function, which cannot cross the @js() JSON
        // boundary. See numberFormatter() below.
        format: config.format ?? null,
        ready: false,
        error: '',
        instance: null,
        ApexCharts: null,
        _mo: null,

        async init() {
            // Deliberately NOT short-circuited on `window.ApexCharts`. The
            // constructor being present says nothing about whether THIS chart's
            // family was loaded: a donut chart that rendered first leaves the
            // constructor on the window, and a treemap next to it would then
            // render blank. loadApexCharts() is itself cached per family.
            this.ApexCharts = await this.loadApexCharts();

            if (!this.ApexCharts) {
                return;
            }

            this.render();

            // Re-theme live when the app flips the `.dark` class on <html>. Token
            // colours (palette/grid/axis/legend/tooltip) are resolved at render
            // time, so without this a runtime theme toggle would leave the chart
            // on its original palette. Re-reads tokens and updates in place.
            this._mo = new MutationObserver(() => this.retheme());
            this._mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
        },

        // Re-resolve the token-driven options for the current theme and apply
        // them without animating (false) or resetting the user's interactions.
        retheme() {
            if (!this.instance) return;
            try {
                this.instance.updateOptions(this.baseOptions(), false, false);
            } catch (e) {
                this.error = String(e);
            }
        },

        // Every family this chart draws: its own `type`, an `options.chart.type`
        // override, and the per-series `type` keys a mixed chart carries.
        families() {
            const types = new Set([this.type, this.options?.chart?.type]);

            for (const series of Array.isArray(this.series) ? this.series : []) {
                types.add(series?.type);
            }

            return [...types].filter(Boolean);
        },

        async loadApexCharts() {
            try {
                const [ApexCharts] = await Promise.all([
                    loadCore(),
                    loadFamilies(this.families()),
                ]);
                window.ApexCharts = ApexCharts;

                return ApexCharts;
            } catch (e) {
                this.error = String(e);

                return null;
            }
        },

        // Resolve a design token (`--chart-1`, `--border`, …) to a real colour.
        // Prefers the shared runtime from `ui/index.js`; falls back to reading
        // the custom property itself (bare HSL channels are wrapped) so the
        // chart still themes when that runtime has not been imported.
        tokenColor(name, fallback = null) {
            const resolved = window.UI?.resolveColor?.(name, this.$root, '');
            if (resolved) return resolved;

            const base = String(name).replace(/^--(?:color-)?/, '').replace(/-color$/, '');
            const styles = getComputedStyle(this.$root);
            for (const prop of [`--${base}-color`, `--color-${base}`, `--${base}`]) {
                const raw = styles.getPropertyValue(prop).trim();
                if (!raw) continue;

                return /^-?\d*\.?\d+\s+-?\d*\.?\d+%\s+-?\d*\.?\d+%/.test(raw) ? `hsl(${raw})` : raw;
            }

            return fallback || getComputedStyle(document.body).color || 'currentColor';
        },

        // Default categorical series palette. Uses the neutral hue-spaced
        // `--chart-1..5` tokens (defined light + dark in ui.css) rather than the
        // semantic status tokens (--primary/--info/--success/--warning/--destructive),
        // so arbitrary categories don't pick up a status meaning (e.g. a 5th
        // category reading as destructive-red). A caller passing an explicit
        // `options.colors` array still wins via the deep-merge in baseOptions().
        palette() {
            return [
                this.tokenColor('--chart-1'),
                this.tokenColor('--chart-2'),
                this.tokenColor('--chart-3'),
                this.tokenColor('--chart-4'),
                this.tokenColor('--chart-5'),
            ];
        },

        // Resolves `format` into Intl.NumberFormat options. The four presets
        // cover what a caller reaches for most; an object (or array, from
        // Blade's associative-array JSON encoding) is passed to
        // Intl.NumberFormat as-is, e.g. { style: 'currency', currency: 'USD',
        // notation: 'compact', maximumFractionDigits: 1 }.
        formatOptions() {
            if (!this.format) return null;

            if (typeof this.format === 'string') {
                return (
                    {
                        currency: { style: 'currency', currency: 'USD' },
                        percent: { style: 'percent' },
                        compact: { notation: 'compact', maximumFractionDigits: 1 },
                        number: {},
                    }[this.format] ?? null
                );
            }

            if (typeof this.format === 'object') {
                return { ...this.format };
            }

            return null;
        },

        // One Intl.NumberFormat built from `format`, in the document's own
        // language (falling back to "en" if that locale — or the format
        // options themselves — is not supported). Reused for the value axis,
        // the tooltip (via tooltipHtml(), which already reads the axis
        // formatter) and data labels. Returns null when `format` is unset, so
        // every call site is a no-op and existing charts are unaffected.
        numberFormatter() {
            const options = this.formatOptions();
            if (!options) return null;

            const locale = document.documentElement.lang || 'en';
            try {
                return new Intl.NumberFormat(locale, options);
            } catch (e) {
                try {
                    return new Intl.NumberFormat('en', options);
                } catch (e2) {
                    return null;
                }
            }
        },

        // Per-series (or per-slice) colours. A series `tone` (or the matching
        // entry of the `tones` prop, for number-list series such as a donut)
        // resolves to its design token; the rest keep the categorical palette
        // by position. Resolved here, so a theme flip re-reads the tokens.
        // A literal series `color` still wins inside ApexCharts.
        seriesColors(palette) {
            const series = Array.isArray(this.series) ? this.series : [];
            const tones = Array.isArray(this.tones) ? this.tones : [];
            const count = Math.max(series.length, tones.length);
            const toneOf = (i) => series[i]?.tone ?? tones[i] ?? null;

            if (![...Array(count).keys()].some((i) => this.toneTokens[toneOf(i)])) {
                return palette;
            }

            return [...Array(count).keys()].map((i) => {
                const token = this.toneTokens[toneOf(i)];

                return token ? this.tokenColor(token) : palette[i % palette.length];
            });
        },

        // Recursively merge plain objects; arrays and scalars from `override`
        // replace the base value wholesale (so passing `xaxis.categories`
        // swaps the array rather than concatenating).
        deepMerge(base, override) {
            const out = Array.isArray(base) ? [...base] : { ...base };

            for (const key of Object.keys(override || {})) {
                const o = override[key];
                const b = out[key];
                const mergeable = (v) =>
                    v && typeof v === 'object' && !Array.isArray(v);

                out[key] = mergeable(o) && mergeable(b) ? this.deepMerge(b, o) : o;
            }

            return out;
        },

        baseOptions() {
            const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            const isDark = document.documentElement.classList.contains('dark');
            const muted = this.tokenColor('--muted-foreground');
            const foreground = this.tokenColor('--foreground');
            const border = this.tokenColor('--border');
            const background = this.tokenColor('--background');
            const palette = this.palette();
            const type = this.options?.chart?.type ?? this.type;
            const isColumn = type === 'bar' && this.options?.plotOptions?.bar?.horizontal !== true;

            // One quiet system: hairline horizontal gridlines only, no axis
            // ticks or borders, muted axis text, a legend of round swatches
            // top-left, and no hover darkening of marks (ApexCharts' default
            // "lighten" filter is what makes bars flicker under the pointer).
            const themed = {
                chart: {
                    type: this.type,
                    height: this.height,
                    toolbar: { show: false },
                    zoom: { enabled: false },
                    animations: {
                        enabled: !reduce,
                        speed: 500,
                        animateGradually: { enabled: true, delay: 40 },
                        dynamicAnimation: { enabled: !reduce, speed: 300 },
                    },
                    fontFamily: 'inherit',
                    foreColor: muted,
                    background: 'transparent',
                    parentHeightOffset: 0,
                    // ApexCharts' own accessibility layer: keyboard-operable
                    // legend buttons, arrow-key navigation between data points
                    // and live announcements. The wrapper root is a labelled
                    // role="group" (not role="img"), so these controls are not
                    // nested inside an image and stay reachable.
                    accessibility: {
                        enabled: true,
                        description: this.$el.getAttribute('aria-label') || undefined,
                    },
                },
                theme: { mode: isDark ? 'dark' : 'light' },
                colors: this.seriesColors(palette),
                stroke: { curve: 'smooth', width: 2, lineCap: 'round' },
                fill: { opacity: 1 },
                markers: { size: 0, strokeWidth: 2, strokeColors: background, hover: { size: 5 } },
                dataLabels: { enabled: false },
                grid: {
                    borderColor: border,
                    strokeDashArray: 0,
                    xaxis: { lines: { show: false } },
                    yaxis: { lines: { show: true } },
                    padding: { top: 0, right: 8, bottom: 0, left: 8 },
                },
                legend: {
                    position: 'top',
                    horizontalAlign: 'left',
                    fontFamily: 'inherit',
                    fontSize: '12px',
                    labels: { colors: foreground },
                    markers: { size: 5, strokeWidth: 0, shape: 'circle', offsetX: -2 },
                    itemMargin: { horizontal: 12, vertical: 4 },
                    onItemHover: { highlightDataSeries: true },
                },
                tooltip: {
                    theme: isDark ? 'dark' : 'light',
                    style: { fontFamily: 'inherit', fontSize: '12px' },
                    marker: { show: true },
                    x: { show: true },
                    intersect: false,
                    shared: true,
                    // The same surface as <x-ui.chart-tooltip>, so an ApexCharts
                    // chart and a hand-drawn SVG chart on one page share one
                    // tooltip. Set `tooltip.custom` to null in `options` to get
                    // ApexCharts' own.
                    custom: (ctx) => this.tooltipHtml(ctx),
                },
                states: {
                    hover: { filter: { type: 'none' } },
                    active: { filter: { type: 'none' } },
                },
                xaxis: {
                    labels: { style: { colors: muted, fontSize: '12px', fontFamily: 'inherit' } },
                    axisBorder: { show: false },
                    axisTicks: { show: false },
                    crosshairs: { stroke: { color: border, dashArray: 0 } },
                    tooltip: { enabled: false },
                },
                yaxis: {
                    labels: { style: { colors: muted, fontSize: '12px', fontFamily: 'inherit' } },
                },
                plotOptions: {
                    bar: {
                        borderRadius: 4,
                        borderRadiusApplication: 'end',
                        columnWidth: '56%',
                        barHeight: '56%',
                    },
                    pie: {
                        expandOnClick: false,
                        donut: { size: '72%' },
                    },
                    radar: {
                        polygons: {
                            strokeColors: border,
                            connectorColors: border,
                            fill: { colors: ['transparent'] },
                        },
                    },
                    heatmap: {
                        radius: 3,
                        enableShades: true,
                        shadeIntensity: 0.55,
                        useFillColorAsStroke: false,
                    },
                    polarArea: {
                        rings: { strokeWidth: 1, strokeColor: border },
                        spokes: { strokeWidth: 1, connectorColors: border },
                    },
                    candlestick: {
                        colors: { upward: this.tokenColor('--success'), downward: this.tokenColor('--destructive') },
                        wick: { useFillColor: true },
                    },
                    bubble: { zScaling: true, minBubbleRadius: 4, maxBubbleRadius: 28 },
                },
            };

            // Bars carry no outline — the default 2px stroke drew a dark rim
            // around every column — and a horizontal bar chart drops the
            // vertical gridlines for horizontal ones.
            if (type === 'bar' || type === 'column' || type === 'rangeBar') {
                themed.stroke = { show: false, width: 0 };
                if (!isColumn && type !== 'rangeBar') {
                    themed.grid.xaxis = { lines: { show: true } };
                    themed.grid.yaxis = { lines: { show: false } };
                }
            }

            // Areas fade from the series colour to nothing so stacked or
            // overlapping series still show the line beneath.
            if (type === 'area' || type === 'rangeArea') {
                themed.fill = {
                    type: 'gradient',
                    gradient: {
                        shadeIntensity: 1,
                        opacityFrom: type === 'rangeArea' ? 0.35 : 0.28,
                        opacityTo: type === 'rangeArea' ? 0.15 : 0.02,
                        stops: [0, 100],
                    },
                };
            }

            // Slices separate on a hairline of page background; the legend
            // moves under the pie where it does not fight the shape.
            if (type === 'pie' || type === 'donut' || type === 'polarArea') {
                themed.stroke = { width: 2, colors: [background], lineCap: 'butt' };
                themed.legend.position = 'bottom';
                themed.legend.horizontalAlign = 'center';
                themed.tooltip.shared = false;
                themed.tooltip.intersect = true;
                themed.tooltip.fillSeriesColor = false;
                if (type === 'polarArea') {
                    themed.fill = { opacity: 0.7 };
                    themed.stroke = { width: 1, colors: [background] };
                }
            }

            // Radar rings share the grid colour and the series fill is soft so
            // overlapping shapes still read.
            if (type === 'radar') {
                themed.fill = { opacity: 0.18 };
                themed.stroke = { width: 2, curve: 'straight' };
                themed.markers = { size: 3, strokeWidth: 0, hover: { size: 5 } };
                themed.grid.yaxis = { lines: { show: false } };
                themed.yaxis = { ...themed.yaxis, show: false };
            }

            // Radial bars default to rounded percentage labels — ApexCharts'
            // built-in total formatter prints the raw average (e.g.
            // "62.6666666%"), which overflows the hollow and overlaps the rings.
            if (type === 'radialBar') {
                themed.stroke = { lineCap: 'round' };
                themed.plotOptions.radialBar = {
                    hollow: { size: '58%' },
                    track: { background: this.tokenColor('--muted'), strokeWidth: '100%', margin: 4 },
                    dataLabels: {
                        name: { color: muted, fontSize: '12px', fontFamily: 'inherit' },
                        value: {
                            color: foreground,
                            fontSize: '20px',
                            fontFamily: 'inherit',
                            fontWeight: 600,
                            formatter: (val) => `${Math.round(val)}%`,
                        },
                        total: {
                            color: muted,
                            fontFamily: 'inherit',
                            formatter: (w) => {
                                const totals = w.globals.seriesTotals;
                                const avg = totals.reduce((a, b) => a + b, 0) / (totals.length || 1);

                                return `${Math.round(avg)}%`;
                            },
                        },
                    },
                };
            }

            // Scatter and bubble plots have no connecting line — show sized
            // markers and a numeric x-axis so paired [x, y] points position
            // correctly.
            if (type === 'scatter' || type === 'bubble') {
                themed.markers = { size: 6, strokeWidth: 0, hover: { sizeOffset: 2 } };
                themed.stroke = { width: 0 };
                themed.fill = { opacity: type === 'bubble' ? 0.7 : 1 };
                themed.xaxis = { ...themed.xaxis, type: 'numeric', tickAmount: 6 };
                themed.tooltip.shared = false;
                themed.tooltip.intersect = true;
            }

            // Treemap tiles read the palette per-tile (distributed); a background
            // stroke separates tiles and shades are disabled so colours stay flat
            // and on-token. The legend is meaningless for a single treemap series.
            if (type === 'treemap') {
                themed.legend = { ...themed.legend, show: false };
                themed.stroke = { width: 2, colors: [background] };
                themed.dataLabels = { enabled: true, style: { fontFamily: 'inherit', fontWeight: 500 } };
                themed.plotOptions.treemap = { distributed: true, enableShades: false, borderRadius: 4 };
            }

            // Heatmap cells separate on a page-background hairline; the value
            // axis carries no gridlines because the cells already tile the plot.
            if (type === 'heatmap') {
                themed.colors = [palette[0]];
                themed.stroke = { width: 2, colors: [background] };
                themed.grid.yaxis = { lines: { show: false } };
                themed.grid.padding = { top: 0, right: 0, bottom: 0, left: 8 };
                themed.legend = { ...themed.legend, show: false };
                themed.tooltip.shared = false;
                themed.tooltip.intersect = true;
            }

            // Candlestick wicks + bodies use the status tokens (up = success,
            // down = destructive), with a datetime axis by default.
            if (type === 'candlestick') {
                themed.stroke = { width: 1 };
                themed.xaxis = { ...themed.xaxis, type: 'datetime' };
                themed.tooltip.shared = false;
                themed.tooltip.intersect = true;
            }

            // Boxplot whiskers/median track the foreground; the box body uses the
            // primary (upper) / info (lower) tokens so it themes in dark mode.
            if (type === 'boxPlot') {
                themed.stroke = { width: 1, colors: [foreground] };
                themed.plotOptions.boxPlot = {
                    colors: {
                        upper: this.tokenColor('--primary'),
                        lower: this.tokenColor('--info'),
                    },
                };
            }

            // A mixed chart fades only its area series; columns and lines in
            // the same plot stay solid. ApexCharts reads these as per-series
            // arrays.
            const seriesTypes = (Array.isArray(this.series) ? this.series : []).map((s) => s?.type);
            if (seriesTypes.some((t) => t === 'area') && seriesTypes.some((t) => t && t !== 'area')) {
                themed.fill = {
                    type: seriesTypes.map((t) => (t === 'area' ? 'gradient' : 'solid')),
                    opacity: 1,
                    gradient: { shadeIntensity: 0, opacityFrom: 0.28, opacityTo: 0.02, stops: [0, 100] },
                };
                themed.stroke = {
                    curve: 'smooth',
                    lineCap: 'round',
                    width: seriesTypes.map((t) => (t === 'column' || t === 'bar' ? 0 : 2)),
                };
            }

            const merged = this.deepMerge(themed, this.options);
            merged.series = this.series;

            // `format` applies last, straight to the merged config, and only
            // where the caller's own `options` has not already put a
            // formatter there — an explicit caller formatter always wins.
            // Horizontal bars plot values on the x-axis, not the y-axis, so
            // the value axis is picked accordingly; tooltipHtml() below reads
            // the same axis for the tooltip, and data labels share the same
            // formatter when a caller turns them on via `options`.
            const formatter = this.numberFormatter();
            if (formatter) {
                const applyFormatter = (val) => {
                    const n = Number(val);

                    return Number.isFinite(n) ? formatter.format(n) : val;
                };
                const horizontal = (type === 'bar' || type === 'rangeBar')
                    && this.options?.plotOptions?.bar?.horizontal === true;
                const valueAxis = horizontal ? 'xaxis' : 'yaxis';
                const withFormatter = (axis) => (
                    axis?.labels?.formatter
                        ? axis
                        : this.deepMerge(axis || {}, { labels: { formatter: applyFormatter } })
                );

                merged[valueAxis] = Array.isArray(merged[valueAxis])
                    ? merged[valueAxis].map(withFormatter)
                    : withFormatter(merged[valueAxis]);

                if (!merged.dataLabels?.formatter) {
                    merged.dataLabels = { ...merged.dataLabels, formatter: applyFormatter };
                }
            }

            return merged;
        },

        // Renders the chart-tooltip markup for ApexCharts' `tooltip.custom`
        // hook. Text is escaped — series names and category labels may come
        // from user data.
        tooltipHtml({ series, seriesIndex, dataPointIndex, w }) {
            const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
            const g = w.globals;
            const shared = w.config.tooltip.shared !== false && series.length > 1;
            const indices = shared ? series.map((_, i) => i) : [seriesIndex];
            const isPie = ['pie', 'donut', 'polarArea', 'radialBar'].includes(w.config.chart.type);
            const title = isPie ? '' : (g.categoryLabels?.[dataPointIndex] ?? g.labels?.[dataPointIndex] ?? '');
            // A horizontal bar plots values on the x-axis, not the y-axis
            // (see baseOptions()'s `format` handling), so its tooltip reads
            // the formatter from there instead.
            const horizontal = w.config.plotOptions?.bar?.horizontal === true;
            const format = (i) => {
                const fmt = horizontal
                    ? w.config.xaxis?.labels?.formatter
                    : (w.config.yaxis?.[i]?.labels?.formatter ?? w.config.yaxis?.[0]?.labels?.formatter);
                const raw = isPie ? series[i] : series[i]?.[dataPointIndex];
                if (raw === undefined || raw === null) return '';
                if (Array.isArray(raw)) return raw.map((v) => esc(v)).join(' – ');

                return esc(typeof fmt === 'function' ? fmt(raw) : raw);
            };
            const rows = (isPie ? [seriesIndex] : indices)
                .filter((i) => !g.collapsedSeriesIndices?.includes(i))
                .map((i) => {
                    const name = isPie ? g.labels?.[i] : g.seriesNames?.[i];
                    const value = format(i);
                    if (value === '') return '';

                    return (
                        `<div data-slot="chart-tooltip-row" class="flex items-center justify-between gap-4">` +
                        `<span class="flex items-center gap-2"><span aria-hidden="true" class="size-2 shrink-0 rounded-full" style="background:${esc(g.colors?.[i])}"></span>` +
                        `<span class="text-muted-foreground">${esc(name)}</span></span>` +
                        `<span class="font-medium tabular-nums">${value}</span></div>`
                    );
                })
                .join('');

            return (
                `<div data-slot="chart-tooltip" class="pointer-events-none z-10 min-w-32 rounded-md border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md">` +
                (title !== '' ? `<p data-slot="chart-tooltip-title" class="mb-1 font-medium text-muted-foreground">${esc(title)}</p>` : '') +
                rows +
                `</div>`
            );
        },

        render() {
            try {
                this.instance = new this.ApexCharts(this.$refs.container, this.baseOptions());
                this.instance.render();
                this.ready = true;
            } catch (e) {
                this.error = String(e);
            }
        },

        // Public: replace the series (and optionally the x-axis categories) in
        // place, so a page control (date range, comparison toggle) can redraw
        // without re-rendering the Blade. Reach it from a surrounding scope via
        // `window.Alpine.$data(el).update({ series, categories })`. Data is
        // copied out of any reactive proxy before ApexCharts sees it, and a
        // family a new per-series `type` introduces is loaded first. Before
        // the first render the state is stored and init() draws with it.
        async update({ series, categories, tones } = {}) {
            if (Array.isArray(series)) {
                this.series = JSON.parse(JSON.stringify(series));
            }
            if (Array.isArray(tones)) {
                this.tones = [...tones];
            }
            if (Array.isArray(categories)) {
                this.options = {
                    ...this.options,
                    xaxis: { ...(this.options?.xaxis ?? {}), categories: [...categories] },
                };
            }
            if (!this.instance) return;
            try {
                await loadFamilies(this.families());
                this.instance.updateOptions(this.baseOptions(), false, true);
            } catch (e) {
                this.error = String(e);
            }
        },

        // Tears down the ApexCharts instance. Consumers may call this directly
        // (e.g. before removing the element) to release the chart.
        destroy() {
            if (this._mo) {
                this._mo.disconnect();
                this._mo = null;
            }
            if (this.instance) {
                this.instance.destroy();
                this.instance = null;
            }
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.8.3
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