macbetter

Charts

Charts

Charts that look like they came from Stocks, Health and Fitness, in every theme, scheme and platform.

Exercise minutes this week Between 22 and 64 minutes a day; Wednesday was the longest.
Exercise minutes this week
DayMinutes
Sun22
Mon41
Tue31
Wed64
Thu28
Fri35
Sat38
"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, .Tooltip inside. 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.tsx and sparkline.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 motion

Add 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-chart

Copy 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.

Exercise minutes by month Exercise rose from 812 minutes in June to 1,120 in November.
Exercise minutes by month
MonthMinutes
Jun812
Jul944
Aug901
Sep1036
Oct988
Nov1120
"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:

  1. 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.
  2. Reads the platform's caption style for axis labels: 10 points on the Mac, 12 on iPhone and iPad, in the system font.
  3. Watches for changes to those attributes anywhere on the page, the root's class and style, 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.

VariableDefaultUsed for
--mb-chart-1 … --mb-chart-6accent, secondary accent, blue, purple, yellow, pinkCategorical series, in order.
--mb-chart-up, --mb-chart-downgreen, redA price that rose or fell.
--mb-chart-move, --mb-chart-exercise, --mb-chart-standpink, a green shifted toward lime, cyanActivity rings and their charts.
--mb-chart-awake, --mb-chart-rem, --mb-chart-core, --mb-chart-deepcoral, cyan, blue, indigoSleep stages.
--mb-chart-grid9% ink in light, 12% in darkGridlines: present, but only just.
--mb-chart-axis, --mb-chart-rulesecondary label, tertiary labelAxis labels, and the scrubbing rule.
--mb-chart-surfacethe card or page backgroundWhat 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 ChartHeader and the scrub prop.
  • Rounded marks. Bars have rounded tops, ranges are capsules, rings have round caps.
  • SF Rounded for metrics. Big numbers use --mb-font-rounded and tabular figures, so digits don't jump as you scrub.

Accessibility

  • ChartFrame makes a chart a figure named by its title and described by its summary. Pass table to give screen readers every value in a visually hidden table. The canvas itself is hidden from assistive technology.
  • EChart with scrub is a slider: focus it, then step through points with the arrow keys, Page Up and Page Down (ten at a time), Home and End. valueText says each point aloud. On a touch screen, a horizontal drag scrubs and a vertical one still scrolls the page.
  • Never use color alone. ChartLegend names 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.