Charts
Charts
Charts that look like they came from Stocks, Health and Fitness, in every theme, scheme and platform.
| Day | Minutes |
|---|---|
| Sun | 22 |
| Mon | 41 |
| Tue | 31 |
| Wed | 64 |
| Thu | 28 |
| Fri | 35 |
| Sat | 38 |
"use client"
import * as React from "react"
import { baseOption, categoryAxis, ChartFrame, EChart, valueAxis, type ChartTheme } from "@/components/charts"
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const minutes = [22, 41, 31, 64, 28, 35, 38]
export default function ExerciseWeek() {
// The option is a function of the resolved theme, so it redraws on a scheme, theme or platform change.
const option = React.useCallback(
(theme: ChartTheme) => ({
...baseOption(theme),
grid: { left: 0, right: 32, top: 8, bottom: 24 },
xAxis: categoryAxis(theme, days),
yAxis: valueAxis(theme, { max: 80, interval: 40 }),
series: [
{
id: "exercise",
type: "bar",
data: minutes,
barWidth: "56%",
itemStyle: { color: theme.colors.exercise, borderRadius: [4, 4, 0, 0] },
},
],
}),
[]
)
return (
<ChartFrame
title="Exercise minutes this week"
summary="Between 22 and 64 minutes a day; Wednesday was the longest."
table={{ columns: ["Day", "Minutes"], rows: days.map((day, i) => [day, minutes[i]!]) }}
style={{ width: "100%", maxWidth: 420 }}
>
<EChart option={option} height={180} />
</ChartFrame>
)
}What's in the box
- ECharts draws everything, on a canvas. It's the only required dependency.
- EvilCharts is a set of chart components for ECharts (area, line, bar, pie, radial, heatmap and more), with a compound API:
<EChartsBarChart>with<EChartsBarChart.Bar>,.XAxis,.Tooltipinside. It isn't an npm package: like shadcn/ui, you add each chart's source to your project with the shadcn CLI and own it from then on. EvilCharts is MIT licensed. - The macbetter adapter turns macbetter tokens into colors a canvas can use, and keeps them current. It's six small files:
theme.ts,echart.tsx,chart-frame.tsx,charts.css,rings.tsxandsparkline.tsx.
Use EvilCharts for the standard charts: it handles legends, tooltips, selection and loading states. Use the adapter's EChart with a plain ECharts option for the charts that are particular to Apple's apps, like a price with volume, heart rate ranges, sleep stages and Activity rings. Both read the same theme.
Install
Add ECharts
EvilCharts animates its overlays with Motion, so add it too.
pnpm add echarts motionAdd the EvilCharts charts you use
EvilCharts is a shadcn registry, so your project needs a components.json (pnpm dlx shadcn@latest init). Then add each chart by name. The CLI adds the chart and the shared modules it imports, like the tooltip and legend.
pnpm dlx shadcn@latest add @evilcharts/echarts-bar-chart
pnpm dlx shadcn@latest add @evilcharts/echarts-area-chart @evilcharts/echarts-line-chart
pnpm dlx shadcn@latest add @evilcharts/echarts-pie-chart @evilcharts/echarts-radial-chart @evilcharts/echarts-heatmap-chartCopy the adapter
Copy apps/docs/src/components/charts/ from the macbetter repository into your app. The examples import it as @/components/charts.
Draw a chart
An EChart takes an ECharts option as a function of the theme. The adapter resolves the theme on the chart's own element and calls the function again when anything changes, so wrap it in useCallback.
"use client"
import * as React from "react"
import { baseOption, categoryAxis, ChartFrame, EChart, valueAxis, type ChartTheme } from "@/components/charts"
export function ExerciseWeek({ minutes }: { minutes: number[] }) {
const option = React.useCallback(
(theme: ChartTheme) => ({
...baseOption(theme), // system font, Reduce Motion, first-draw animation
xAxis: categoryAxis(theme, ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]),
yAxis: valueAxis(theme), // labels on the trailing edge, hairline grid
series: [{ type: "bar", data: minutes, itemStyle: { color: theme.colors.exercise, borderRadius: [4, 4, 0, 0] } }],
}),
[minutes]
)
return (
<ChartFrame title="Exercise minutes this week" summary="Wednesday was the longest, at 64 minutes.">
<EChart option={option} height={180} />
</ChartFrame>
)
}An EvilCharts chart takes a config of series colors and a chartOptions escape hatch. useEvilChart builds both from macbetter chart colors. Put its ref on the ChartFrame, and render the chart once config is ready; canvas charts draw on the client only.
| Month | Minutes |
|---|---|
| Jun | 812 |
| Jul | 944 |
| Aug | 901 |
| Sep | 1036 |
| Oct | 988 |
| Nov | 1120 |
"use client"
import { ChartFrame, useEvilChart } from "@/components/charts"
import { EChartsAreaChart } from "@/components/charts/evil/charts/echarts-area-chart"
const data = [
{ month: "Jun", minutes: 812 },
{ month: "Jul", minutes: 944 },
{ month: "Aug", minutes: 901 },
{ month: "Sep", minutes: 1036 },
{ month: "Oct", minutes: 988 },
{ month: "Nov", minutes: 1120 },
]
// Colors are macbetter chart colors, not hex: the adapter resolves them for this chart's theme and scheme.
const series = { minutes: { label: "Exercise minutes", color: "exercise" } } as const
export default function ExerciseByMonth() {
const { ref, config, chartOptions } = useEvilChart(series)
return (
<ChartFrame
ref={ref}
title="Exercise minutes by month"
summary="Exercise rose from 812 minutes in June to 1,120 in November."
table={{ columns: ["Month", "Minutes"], rows: data.map((d) => [d.month, d.minutes]) }}
style={{ width: "100%", maxWidth: 420 }}
>
<div style={{ height: 180 }}>
{config && chartOptions ? (
<EChartsAreaChart data={data} config={config} chartOptions={chartOptions} xDataKey="month" curveType="monotone" className="h-full w-full">
<EChartsAreaChart.Grid />
<EChartsAreaChart.XAxis dataKey="month" hideDots />
<EChartsAreaChart.YAxis hideDots />
<EChartsAreaChart.Tooltip roundness="lg" />
<EChartsAreaChart.Area dataKey="minutes" variant="gradient" strokeWidth={2.5}>
<EChartsAreaChart.ActiveDot variant="border" />
</EChartsAreaChart.Area>
</EChartsAreaChart>
) : null}
</div>
</ChartFrame>
)
}How the theme works
macbetter colors are CSS: light-dark(), color-mix() and oklch(), chosen by data-theme, data-scheme and data-platform on any ancestor. A canvas can't read CSS, so the adapter:
- Paints each token on a hidden probe inside the chart's container and reads back a plain
rgba(). A chart inside a nested light preview, or a different theme, gets that preview's colors. - Reads the platform's caption style for axis labels: 10 points on the Mac, 12 on iPhone and iPad, in the system font.
- Watches for changes to those attributes anywhere on the page, the root's
classandstyle, and the system's appearance, contrast and motion settings, then redraws only the charts whose colors changed.
theme.colors has the system palette (red, orange, … pink), labels (label, label2, label3), fills, and the chart colors below. Every one is a CSS variable you can set in a theme or on any element.
| Variable | Default | Used for |
|---|---|---|
--mb-chart-1 … --mb-chart-6 | accent, secondary accent, blue, purple, yellow, pink | Categorical series, in order. |
--mb-chart-up, --mb-chart-down | green, red | A price that rose or fell. |
--mb-chart-move, --mb-chart-exercise, --mb-chart-stand | pink, a green shifted toward lime, cyan | Activity rings and their charts. |
--mb-chart-awake, --mb-chart-rem, --mb-chart-core, --mb-chart-deep | coral, cyan, blue, indigo | Sleep stages. |
--mb-chart-grid | 9% ink in light, 12% in dark | Gridlines: present, but only just. |
--mb-chart-axis, --mb-chart-rule | secondary label, tertiary label | Axis labels, and the scrubbing rule. |
--mb-chart-surface | the card or page background | What a chart sits on, for cutouts and dark-mode detection. |
EvilCharts reads shadcn's color utilities (text-muted-foreground, text-border, bg-background) for its gridlines, labels and tooltip. Inside a ChartFrame, charts.css maps them to these variables, so its tooltip becomes a macbetter popup material.
The Apple look
What makes a chart read as Apple's, and what the adapter's helpers set for you:
- Quiet axes. No axis lines, no ticks. Values on the trailing edge, in secondary label color. Solid hairline gridlines, with dashed ones for time dividers.
- One idea per color. Activity is orange, heart is pink, sleep is blue and indigo; up is green and down is red. A series that matters gets color; context gets gray.
- Numbers in a header, not a tooltip. Health puts the reading above the chart and changes it as you scrub; Stocks pins the price and time at the top of the rule. Use
ChartHeaderand thescrubprop. - Rounded marks. Bars have rounded tops, ranges are capsules, rings have round caps.
- SF Rounded for metrics. Big numbers use
--mb-font-roundedand tabular figures, so digits don't jump as you scrub.
Accessibility
ChartFramemakes a chart afigurenamed by itstitleand described by itssummary. Passtableto give screen readers every value in a visually hidden table. The canvas itself is hidden from assistive technology.EChartwithscrubis aslider: focus it, then step through points with the arrow keys,Page UpandPage Down(ten at a time),HomeandEnd.valueTextsays each point aloud. On a touch screen, a horizontal drag scrubs and a vertical one still scrolls the page.- Never use color alone.
ChartLegendnames every series in text, and the examples say "up" or "down" in words beside a green or red number. - Charts draw without animation under Reduce Motion, and follow Increase Contrast through the tokens.
Bundle size
ECharts is large if you import all of it. The adapter and every EvilCharts chart import from echarts/core and register only the series and components they draw, so you pay for bars, lines and gauges, not maps and 3D. motion is only needed by EvilCharts.