- Components
- chart
Chart
Bar, line, area, pie, donut and radial charts rendered by Blade, with no JavaScript library.
Demo
Monthly visitors
January – June 2026
| Month | Desktop | Mobile |
|---|---|---|
| Jan | 186 | 80 |
| Feb | 305 | 200 |
| Mar | 237 | 120 |
| Apr | 73 | 190 |
| May | 209 | 130 |
| Jun | 214 | 140 |
@php
$visitors = [
['month' => 'Jan', 'desktop' => 186, 'mobile' => 80],
['month' => 'Feb', 'desktop' => 305, 'mobile' => 200],
['month' => 'Mar', 'desktop' => 237, 'mobile' => 120],
['month' => 'Apr', 'desktop' => 73, 'mobile' => 190],
['month' => 'May', 'desktop' => 209, 'mobile' => 130],
['month' => 'Jun', 'desktop' => 214, 'mobile' => 140],
];
@endphp
<div class="w-full max-w-xl rounded-xl border border-border bg-background p-5">
<p class="font-medium text-foreground">Monthly visitors</p>
<p class="text-sm text-muted-foreground">January – June 2026</p>
<x-ui.chart :data="$visitors" index="month" label="Monthly visitors" class="mt-5 h-64" :config="[
'desktop' => ['label' => 'Desktop', 'color' => 'var(--chart-1)'],
'mobile' => ['label' => 'Mobile', 'color' => 'var(--chart-2)'],
]" />
</div>
Installation
php artisan flexi:add chart
This adds the Blade views under resources/views/components/ui/chart
, the App\Flexiwind\ChartHelper
class that computes the geometry, and a small script, resources/js/plugins/chart.js
, for tooltips and interactions. Register it once next to the other Flexiwind plugins:
import { ChartPlugin } from "./plugins/chart";
Alpine.plugin(ChartPlugin);
Without Alpine, call initChartInteractions()
from the same file once. There is nothing to set up per chart, and charts replaced by Livewire keep working.
Chart types
| Type | Page |
|---|---|
bar
, with stacked
or horizontal
|
Bar Chart |
line
, area
|
Line Chart |
pie
, donut
|
Pie Chart |
radial
|
Radial Chart |
Two companions live next to the chart for dashboards: Sparkline , a tiny trend for KPI cards, and Tracker , a row of status blocks.
Need zoom on a long time series, candlesticks, a heatmap or thousands of points? The ECharts plugin takes the same data and draws it with Apache ECharts.
Why no chart library
The chart is drawn on the server: bars and points are positioned in percentages, lines are SVG paths in a stretched viewBox. That means:
- Almost nothing to load. No chart library: the chart script weighs about 3 KB gzipped, and the chart is fully drawn before it runs.
- Livewire just works. When the data changes, Livewire morphs the HTML and the bars animate to their new height.
- Your theme applies. Colors are CSS variables, so dark mode and themes follow without code.
- Readable without sight. Every chart carries a hidden data table for screen readers.
Data and config
data
is a list of rows, one per category — the shape an Eloquent query or collect()
already gives you. index
names the category column. config
lists the series to draw, in order, with their label and color.
@php
$data = [
['month' => 'Jan', 'desktop' => 186, 'mobile' => 80],
['month' => 'Feb', 'desktop' => 305, 'mobile' => 200],
];
@endphp
<x-ui.chart :data="$data" index="month" :config="[
'desktop' => ['label' => 'Desktop', 'color' => 'var(--chart-1)'],
'mobile' => ['label' => 'Mobile', 'color' => 'var(--chart-2)'],
]" />
Without config
, every numeric column becomes a series and colors cycle through --chart-1
to --chart-5
. A null
value is shown as missing, never as zero.
Pie, donut and radial charts color categories rather than series, so their config
is keyed by category: 'chrome' => ['label' => 'Chrome', 'color' => …]
.
Composition
Without content, <x-ui.chart>
draws a complete chart for its type
. Give it content and it becomes a frame: each layer is a component you can keep, remove, reorder or restyle with classes. Layers stack in the order you write them.
| Month | Revenue | Target |
|---|---|---|
| Jan | €12.4k | €11k |
| Feb | €13.9k | €12k |
| Mar | €11.8k | €13k |
| Apr | €16.2k | €14k |
| May | €17.8k | €15k |
| Jun | €19.4k | €16k |
Interactions
Every bar, line and area chart gets a hover tooltip, and tapping a category shows it on touch screens. A chart has a single tooltip, whatever its number of categories: the script fills it from the chart's data table as the pointer moves, so a year of daily data does not add hundreds of hidden panels to the page.
Mark a chart interactive
to add:
- Keyboard: tab into the chart, then use the arrow keys, Home and End. Screen readers announce each value.
- Legend toggle: click a series in the legend to hide or show it. The value axis keeps its scale.
-
CSV export: put
data-chart-export="#chart-id"on any button. Values are exported raw —1250, not1.3k.
Revenue by stream
Tab into the chart, then use the arrow keys.
Use the arrow keys to read each value.
| Month | Subscriptions | Services | Licenses |
|---|---|---|---|
| Jan | €12.4k | €5.2k | €3.1k |
| Feb | €13.9k | €4.8k | €3.6k |
| Mar | €11.8k | €6.1k | €2.9k |
| Apr | €16.2k | €5.6k | €4.2k |
| May | €17.8k | €7.3k | €3.9k |
| Jun | €19.4k | €6.9k | €4.8k |
Without the script, the chart is still drawn and its data table is still read by screen readers; only the tooltip and the interactions are missing.
Hiding a series does not rescale the chart. To rescale, filter the data on the server — for example from a Livewire property — and the chart is recomputed.
Range selection
Add brush
and the chart lets people select a range of categories: drag across it, or hold Shift with the arrow keys and press Enter. It then emits a chart-range
DOM event:
{ start: 12, end: 30, from: "Jul 13", to: "Jul 31" }
start
and end
are indexes in what the chart shows. A double-click emits the same event with null
values, to go back.
Listen to it from Livewire and recompute the data on the server — the scale follows the zoom. Drag across this chart:
Daily revenue
Jul 1 – Sep 28 · €518,916
Drag across the chart to zoom
Use the arrow keys to read each value. Hold Shift with the arrow keys to select a range, then press Enter.
| Day | Revenue |
|---|---|
| Jul 1 | €4.2k |
| Jul 2 | €4.5k |
| Jul 3 | €4.7k |
| Jul 4 | €4.9k |
| Jul 5 | €5.1k |
| Jul 6 | €3.8k |
| Jul 7 | €5.3k |
| Jul 8 | €5.4k |
| Jul 9 | €5.3k |
| Jul 10 | €5.2k |
| Jul 11 | €5.1k |
| Jul 12 | €5k |
| Jul 13 | €3.4k |
| Jul 14 | €4.6k |
| Jul 15 | €4.4k |
| Jul 16 | €4.3k |
| Jul 17 | €4.1k |
| Jul 18 | €4k |
| Jul 19 | €4k |
| Jul 20 | €2.6k |
| Jul 21 | €4.1k |
| Jul 22 | €4.2k |
| Jul 23 | €4.4k |
| Jul 24 | €4.6k |
| Jul 25 | €4.9k |
| Jul 26 | €5.1k |
| Jul 27 | €4k |
| Jul 28 | €5.6k |
| Jul 29 | €5.9k |
| Jul 30 | €6k |
| Jul 31 | €6.2k |
| Aug 1 | €6.3k |
| Aug 2 | €6.3k |
| Aug 3 | €4.9k |
| Aug 4 | €6.2k |
| Aug 5 | €6.1k |
| Aug 6 | €5.9k |
| Aug 7 | €5.8k |
| Aug 8 | €5.6k |
| Aug 9 | €5.4k |
| Aug 10 | €3.8k |
| Aug 11 | €5.1k |
| Aug 12 | €5k |
| Aug 13 | €5k |
| Aug 14 | €5k |
| Aug 15 | €5k |
| Aug 16 | €5.2k |
| Aug 17 | €3.9k |
| Aug 18 | €5.5k |
| Aug 19 | €5.8k |
| Aug 20 | €6k |
| Aug 21 | €6.3k |
| Aug 22 | €6.6k |
| Aug 23 | €6.8k |
| Aug 24 | €5.6k |
| Aug 25 | €7.1k |
| Aug 26 | €7.2k |
| Aug 27 | €7.3k |
| Aug 28 | €7.2k |
| Aug 29 | €7.2k |
| Aug 30 | €7.1k |
| Aug 31 | €5.5k |
| Sep 1 | €6.7k |
| Sep 2 | €6.6k |
| Sep 3 | €6.4k |
| Sep 4 | €6.2k |
| Sep 5 | €6.1k |
| Sep 6 | €6k |
| Sep 7 | €4.5k |
| Sep 8 | €5.9k |
| Sep 9 | €6k |
| Sep 10 | €6.1k |
| Sep 11 | €6.3k |
| Sep 12 | €6.5k |
| Sep 13 | €6.7k |
| Sep 14 | €5.6k |
| Sep 15 | €7.2k |
| Sep 16 | €7.5k |
| Sep 17 | €7.7k |
| Sep 18 | €7.9k |
| Sep 19 | €8.1k |
| Sep 20 | €8.2k |
| Sep 21 | €6.8k |
| Sep 22 | €8.2k |
| Sep 23 | €8.1k |
| Sep 24 | €8k |
| Sep 25 | €7.9k |
| Sep 26 | €7.7k |
| Sep 27 | €7.5k |
| Sep 28 | €6k |
<x-ui.chart type="area" brush :data="$days" index="day"
wire:chart-range="zoom($event.detail.start, $event.detail.end)" />
#[Locked]
public int $from = 0;
#[Locked]
public int $to = 89;
public function zoom(mixed $start = null, mixed $end = null): void
{
if ($start === null || $end === null) {
$this->reset('from', 'to');
return;
}
// Indexes come from the browser: check them before using them.
if (! is_int($start) || ! is_int($end) || $start < 0 || $end <= $start || $end > $this->to - $this->from) {
return;
}
[$this->from, $this->to] = [$this->from + $start, $this->from + $end];
}
With Alpine instead of Livewire: x-on:chart-range="range = $event.detail"
.
Theming
Series colors come from five CSS variables defined by your theme, --chart-1
to --chart-5
. They are assigned in order: the first series always takes --chart-1
, the second --chart-2
, and so on — a color follows the series, not its rank.
--chart-1
is your theme's primary color, so a single-series chart matches your brand. The four others are distinct hues chosen for each theme, not tints of the primary: two shades of the same color are hard to tell apart in a legend. Every theme's palette is checked in light and dark mode for:
- Color blindness: neighboring colors stay distinct under protanopia and deuteranopia.
- Normal vision: neighbors are clearly different, and no hue appears twice.
- Pies: the last slice touches the first, so that pair is checked too.
:root {
--chart-1: var(--primary);
--chart-2: #eb6834;
--chart-3: #1baf7a;
--chart-4: #eda100;
--chart-5: #e87ba4;
}
Beyond five series, a chart gets hard to read: group the smallest into "Other" rather than adding colors. Any CSS color also works in config
— #6366f1
, oklch(62% 0.2 264)
, var(--success)
— but status colors such as --success
or --destructive
are best kept for series that mean good or bad.
API
Chart
| Prop | Description |
|---|---|
data
(array|Collection)
|
Rows, one per category. |
index
(string)
|
Column holding the category label. Defaults to the first non-numeric column. |
config
(array)
|
Series to draw: ['key' => ['label' => …, 'color' => …]]
.
|
type
(bar|line|area|pie|donut|radial)
|
What the chart draws when it has no content. Defaults to bar
.
|
stacked
(boolean)
|
Stacks bars instead of grouping them. |
horizontal
(boolean)
|
Lays bars down, categories on the vertical axis. |
min
, max
(number)
|
Force the bounds of the value axis. For radial
, max
is the value of a full ring (100 by default).
|
format
(compact|number|percent)
|
Value format on the axis and in the tooltip. compact
gives 1.2k
.
|
prefix
, suffix
(string)
|
Around every value: prefix="€"
, suffix=" ms"
.
|
curve
(smooth|linear)
|
Line interpolation when type
is line
or area
.
|
legend
(boolean|null)
|
Shown by default when there are two series or more. |
label
(string)
|
Accessible name of the chart and caption of its data table. |
interactive
(boolean)
|
Keyboard navigation and legend toggle. Bar, line and area charts. |
brush
(boolean)
|
Range selection, emitting chart-range
. Implies interactive
.
|
Size the chart with a height class on the component: class="h-80"
.
Layers
| Component | Props |
|---|---|
<x-ui.chart.grid />
|
vertical
(boolean) adds a line per category.
|
<x-ui.chart.y-axis />
|
— |
<x-ui.chart.x-axis />
|
interval
(int) keeps one label every N.
|
<x-ui.chart.bar />
|
radius
(px), gap
(0 to 0.9, space between categories), only
(series keys).
|
<x-ui.chart.line />
|
curve
, area
, dots
, stroke-width
, only
.
|
<x-ui.chart.tooltip />
|
cursor
(boolean). Write it last so it sits on top. One panel per chart, filled by the chart script.
|
<x-ui.chart.pie />
|
inner
(0 to 0.9, hole size), series
, center-label
. Its slot fills the center.
|
<x-ui.chart.radial />
|
thickness
, gap
, half
, track
, series
. Its slot fills the center.
|
Accessibility
The drawing is hidden from assistive technologies. Instead, the chart renders a visually hidden <table>
with a caption taken from label
, one row per category and one column per series. Always give your chart a meaningful label
.