Charts
Token-themed data visualisations — the core multi-series chart plus dependency-free SVG variants for sankey, word-cloud, venn and violin plots.
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.
<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
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:
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.
-
resources/views/components/ui/chart.blade.php -
resources/js/ui/chart.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejsnpm: 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.
# 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
<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') }}"
/>
{{-- 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
{{-- 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>
{{-- 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>
<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
@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') }}"
/>
<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` 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') }}"
/>
{{-- 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>
@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
{{-- 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
{{-- 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>
<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') }}"
/>
<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') }}"
/>
{{-- 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>
<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>
{{-- 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>
<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 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>
{{-- 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>
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="chart-{{ $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.
{{--
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>
/**
* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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