macbetter

Charts

Health

Bars with rounded tops, ranges as capsules, a night of sleep in four lanes, and a header that follows your finger.

Average
8,214steps
Nov 8–14, 2026
Steps, Nov 8–14, 2026 Daily average: 8,214 steps. Highest: 11,204.
Steps, Nov 8–14, 2026
PeriodSteps
Sun, Nov 86,412
Mon, Nov 99,832
Tue, Nov 107,120
Wed, Nov 1111,204
Thu, Nov 128,530
Fri, Nov 137,980
Today6,421
"use client"

import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
import * as React from "react"
import { baseOption, categoryAxis, ChartFrame, ChartHeader, EChart, formatters, valueAxis, type ChartTheme, type Scrub } from "@/components/charts"

type Period = "D" | "W" | "M" | "6M" | "Y"

/** Steps for Saturday, November 14, 2026 and the periods before it. */
const steps: Record<Period, { labels: string[]; ticks: number[]; values: number[]; dates: string[]; range: string }> = {
  D: {
    labels: ["12 AM", "", "", "", "", "", "6", "", "", "", "", "", "12 PM", "", "", "", "", "", "6", "", "", "", "", ""],
    ticks: [0, 6, 12, 18],
    values: [0, 0, 0, 0, 0, 0, 112, 864, 1420, 388, 210, 642, 1190, 455, 302, 268, 980, 1640, 720, 312, 184, 96, 0, 0],
    dates: Array.from({ length: 24 }, (_, h) => `${h % 12 || 12}–${(h + 1) % 12 || 12} ${h + 1 < 12 || h + 1 === 24 ? "AM" : "PM"}`),
    range: "Today",
  },
  W: {
    labels: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
    ticks: [0, 1, 2, 3, 4, 5, 6],
    values: [6412, 9832, 7120, 11204, 8530, 7980, 6421],
    dates: ["Sun, Nov 8", "Mon, Nov 9", "Tue, Nov 10", "Wed, Nov 11", "Thu, Nov 12", "Fri, Nov 13", "Today"],
    range: "Nov 8–14, 2026",
  },
  M: {
    labels: Array.from({ length: 30 }, (_, i) => (i % 7 === 0 ? String(((i + 15) % 31) + 1) : "")),
    ticks: [0, 7, 14, 21, 28],
    values: [8120, 7311, 9954, 6203, 5480, 10422, 8805, 7650, 9120, 8302, 6127, 12040, 7765, 8410, 9302, 6650, 5980, 11210, 8034, 7480, 9933, 8320, 6412, 9832, 7120, 11204, 8530, 7980, 6421, 4210],
    dates: Array.from({ length: 30 }, (_, i) => {
      const date = new Date(2026, 9, 16 + i)
      return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
    }),
    range: "Oct 16–Nov 14, 2026",
  },
  "6M": {
    labels: Array.from({ length: 26 }, (_, i) => {
      const date = new Date(2026, 4, 17 + i * 7)
      return date.getDate() <= 7 ? date.toLocaleDateString("en-US", { month: "short" }) : ""
    }),
    ticks: [],
    values: [6980, 7210, 7544, 8102, 7720, 8340, 8611, 9020, 8790, 8455, 7980, 8230, 8875, 9310, 9104, 8650, 8220, 7905, 8433, 8701, 8920, 8312, 7840, 8105, 8540, 8214],
    dates: Array.from({ length: 26 }, (_, i) => {
      const start = new Date(2026, 4, 17 + i * 7)
      return `Week of ${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
    }),
    range: "May 17–Nov 14, 2026",
  },
  Y: {
    labels: ["D", "J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N"],
    ticks: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
    values: [6120, 6480, 6902, 7340, 7810, 8420, 9105, 9380, 8960, 8510, 8302, 8214],
    dates: ["December 2025", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November"],
    range: "Dec 2025–Nov 2026",
  },
}

export default function StepsChart() {
  const [period, setPeriod] = React.useState<Period>("W")
  const [scrub, setScrub] = React.useState<Scrub | null>(null)
  const data = steps[period]
  const total = data.values.reduce((a, b) => a + b, 0)
  const average = Math.round(total / data.values.length)
  // A day's total, or the daily average over a longer period; 6M and Y bars are already averages.
  const headline = period === "D" ? total : average

  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 40, top: 8, bottom: 24 },
      xAxis: categoryAxis(theme, data.labels, {
        axisLabel: {
          color: theme.colors.axis,
          fontSize: theme.fontSize,
          interval: (i: number) => data.labels[i] !== "",
          align: period === "W" || period === "Y" ? "center" : "left",
        },
        splitLine: {
          show: true,
          interval: (i: number) => data.ticks.includes(i) && i > 0,
          lineStyle: { color: theme.colors.grid, type: [2, 3] },
        },
      }),
      yAxis: valueAxis(theme, { splitNumber: 3 }),
      series: [
        {
          id: "steps",
          type: "bar",
          data: data.values,
          barWidth: period === "D" ? "58%" : period === "M" ? "62%" : "56%",
          itemStyle: { color: theme.colors.orange, borderRadius: [3, 3, 0, 0] },
          emphasis: { disabled: true },
        },
      ],
    }),
    [data, period]
  )

  const index = scrub?.index
  return (
    <ChartFrame
      title={`Steps, ${data.range}`}
      summary={`${period === "D" ? "Total" : "Daily average"}: ${formatters.integer.format(headline)} steps. Highest: ${formatters.integer.format(Math.max(...data.values))}.`}
      table={{ columns: ["Period", "Steps"], rows: data.values.map((v, i) => [data.dates[i]!, formatters.integer.format(v)]) }}
      style={{ width: "100%", maxWidth: 560 }}
    >
      <SegmentedControl size="sm" block aria-label="Period" value={period} onValueChange={(v) => v && setPeriod(v as Period)}>
        {(["D", "W", "M", "6M", "Y"] as const).map((p) => (
          <SegmentedControlItem key={p} value={p} aria-label={{ D: "Day", W: "Week", M: "Month", "6M": "6 months", Y: "Year" }[p]}>
            {p}
          </SegmentedControlItem>
        ))}
      </SegmentedControl>
      <ChartHeader
        eyebrow={index === undefined ? (period === "D" ? "Total" : "Average") : period === "6M" || period === "Y" ? "Average" : "Total"}
        value={formatters.integer.format(index === undefined ? headline : data.values[index]!)}
        unit="steps"
        detail={index === undefined ? data.range : data.dates[index]}
      />
      <EChart
        option={option}
        height={220}
        onScrub={setScrub}
        scrub={{
          label: "Steps",
          count: data.values.length,
          valueText: (i) => `${formatters.integer.format(data.values[i]!)} steps, ${data.dates[i]}`,
        }}
      />
    </ChartFrame>
  )
}

How Health draws a chart

Every Health chart has the same three parts, top to bottom:

  • A period picker. D, W, M, 6M and Y in a segmented control. Short periods show totals; long ones show averages, so a year of steps is twelve monthly daily averages, not twelve sums.
  • A header that reads the chart. A small caps label (Total, Average, Range), the value in SF Rounded with its unit, and the date. When you scrub, the header switches to the bar under your finger. That's where the number goes, not in a tooltip, so nothing covers the bars.
  • A quiet plot. Values on the trailing edge, solid hairline gridlines, dashed dividers at days or months, no axis lines, and one color per kind of data: orange for activity, pink for heart, blue and indigo for sleep.

ChartHeader draws the header, and EChart's scrub prop does the rest: a vertical rule follows the pointer or a finger, and focus with the arrow keys works the same way.

Steps

Bars with rounded tops in the activity color. The chart above switches periods and changes what the header calls the number: a day's total, or the daily average for longer periods.

Heart rate

Heart rate is a range, not a value, so each hour is a capsule from its lowest to its highest reading. An invisible bar lifts each capsule to its minimum; borderRadius: 99 rounds both ends.

Range
52–148BPM
Today
Heart rate, Today Heart rate ranged from 52 to 148 beats per minute. The highest readings came during the evening walk.
Heart rate, Today
TimeLowest (BPM)Highest (BPM)
12–1 AM5461
1–2 AM5258
2–3 AM5360
3–4 AM5562
4–5 AM5359
5–6 AM5663
6–7 AM5874
7–8 AM66112
8–9 AM72104
9–10 AM7088
10–11 AM6892
11–12 PM7196
12–1 PM74102
1–2 PM6994
2–3 PM6790
3–4 PM7098
4–5 PM88142
5–6 PM96148
6–7 PM78118
7–8 PM7296
8–9 PM6888
9–10 PM6480
10–11 PM6072
11–12 AM5764
"use client"

import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
import * as React from "react"
import { baseOption, categoryAxis, ChartFrame, ChartHeader, EChart, valueAxis, type ChartTheme, type Scrub } from "@/components/charts"

type Period = "D" | "W"

/** Lowest and highest heart rate, in BPM, for each hour today and each day this week. */
const ranges: Record<Period, { labels: string[]; ticks: number[]; low: number[]; high: number[]; names: string[]; range: string }> = {
  D: {
    labels: ["12 AM", "", "", "", "", "", "6", "", "", "", "", "", "12 PM", "", "", "", "", "", "6", "", "", "", "", ""],
    ticks: [6, 12, 18],
    low: [54, 52, 53, 55, 53, 56, 58, 66, 72, 70, 68, 71, 74, 69, 67, 70, 88, 96, 78, 72, 68, 64, 60, 57],
    high: [61, 58, 60, 62, 59, 63, 74, 112, 104, 88, 92, 96, 102, 94, 90, 98, 142, 148, 118, 96, 88, 80, 72, 64],
    names: Array.from({ length: 24 }, (_, h) => `${h % 12 || 12}–${(h + 1) % 12 || 12} ${h + 1 < 12 || h + 1 === 24 ? "AM" : "PM"}`),
    range: "Today",
  },
  W: {
    labels: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
    ticks: [],
    low: [51, 53, 52, 50, 54, 53, 52],
    high: [138, 152, 121, 164, 146, 129, 148],
    names: ["Sun, Nov 8", "Mon, Nov 9", "Tue, Nov 10", "Wed, Nov 11", "Thu, Nov 12", "Fri, Nov 13", "Today"],
    range: "Nov 8–14, 2026",
  },
}

export default function HeartRateChart() {
  const [period, setPeriod] = React.useState<Period>("D")
  const [scrub, setScrub] = React.useState<Scrub | null>(null)
  const data = ranges[period]
  const low = Math.min(...data.low)
  const high = Math.max(...data.high)

  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 34, top: 8, bottom: 24 },
      xAxis: categoryAxis(theme, data.labels, {
        axisLabel: {
          color: theme.colors.axis,
          fontSize: theme.fontSize,
          interval: (i: number) => data.labels[i] !== "",
          align: period === "D" ? "left" : "center",
        },
        splitLine: {
          show: data.ticks.length > 0,
          interval: (i: number) => data.ticks.includes(i),
          lineStyle: { color: theme.colors.grid, type: [2, 3] },
        },
      }),
      yAxis: valueAxis(theme, { min: 40, max: 200, interval: 40, axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize } }),
      series: [
        // An invisible bar lifts each capsule to the hour's lowest reading.
        { id: "base", type: "bar", stack: "range", data: data.low, itemStyle: { color: "transparent" }, emphasis: { disabled: true } },
        {
          id: "range",
          type: "bar",
          stack: "range",
          data: data.high.map((h, i) => h - data.low[i]!),
          barWidth: period === "D" ? 7 : 12,
          itemStyle: { color: theme.colors.pink, borderRadius: 99 },
          emphasis: { disabled: true },
        },
      ],
    }),
    [data, period]
  )

  const index = scrub?.index
  const shown = index === undefined ? { low, high, when: data.range } : { low: data.low[index]!, high: data.high[index]!, when: data.names[index]! }
  return (
    <ChartFrame
      title={`Heart rate, ${data.range}`}
      summary={`Heart rate ranged from ${low} to ${high} beats per minute. The highest readings came during the evening walk.`}
      table={{ columns: ["Time", "Lowest (BPM)", "Highest (BPM)"], rows: data.names.map((name, i) => [name, data.low[i]!, data.high[i]!]) }}
      style={{ width: "100%", maxWidth: 560 }}
    >
      <SegmentedControl size="sm" block aria-label="Period" value={period} onValueChange={(v) => v && setPeriod(v as Period)}>
        <SegmentedControlItem value="D" aria-label="Day">
          D
        </SegmentedControlItem>
        <SegmentedControlItem value="W" aria-label="Week">
          W
        </SegmentedControlItem>
      </SegmentedControl>
      <ChartHeader eyebrow="Range" value={`${shown.low}–${shown.high}`} unit="BPM" detail={shown.when} />
      <EChart
        option={option}
        height={220}
        onScrub={setScrub}
        scrub={{
          label: "Heart rate",
          count: data.low.length,
          valueText: (i) => `${data.low[i]} to ${data.high[i]} BPM, ${data.names[i]}`,
        }}
      />
    </ChartFrame>
  )
}

Sleep stages

A night of sleep is a timeline in four lanes: Awake, REM, Core and Deep. Each stretch is a rounded bar in its lane, and hairlines shaded from one stage's color to the next join them, the way the Sleep chart does. The legend gives each stage's time in words.

The stages are drawn with an ECharts custom series. --mb-chart-awake, --mb-chart-rem, --mb-chart-core and --mb-chart-deep set the colors.

Time asleep
7 hr 12min
Nov 12–13, 2026
  • Awake24 min
  • REM1 hr 35 min
  • Core4 hr 14 min
  • Deep1 hr 23 min
Sleep stages, Thursday night Asleep 7 hr 12 min, from 11:20 PM to 6:48 AM. Deep 1 hr 23 min, Core 4 hr 14 min, REM 1 hr 35 min, awake 24 min.
Sleep stages, Thursday night
StageFromTo
Awake11:12 PM11:20 PM
Core11:20 PM11:50 PM
Deep11:50 PM12:30 AM
Core12:30 AM1:05 AM
REM1:05 AM1:25 AM
Core1:25 AM2:00 AM
Deep2:00 AM2:28 AM
Core2:28 AM3:00 AM
Awake3:00 AM3:04 AM
Core3:04 AM3:40 AM
REM3:40 AM4:12 AM
Core4:12 AM4:50 AM
Deep4:50 AM5:05 AM
Core5:05 AM5:35 AM
REM5:35 AM6:10 AM
Awake6:10 AM6:14 AM
Core6:14 AM6:32 AM
REM6:32 AM6:40 AM
Awake6:40 AM6:48 AM
"use client"

import * as React from "react"
import { baseOption, ChartFrame, ChartHeader, ChartLegend, EChart, echarts, type ChartColor, type ChartTheme } from "@/components/charts"

const stages = [
  { name: "Deep", color: "deep" },
  { name: "Core", color: "core" },
  { name: "REM", color: "rem" },
  { name: "Awake", color: "awake" },
] as const satisfies { name: string; color: ChartColor }[]

type Stage = (typeof stages)[number]["name"]

/** Thursday night into Friday: each stage with its start and end, in minutes after 10 PM. */
const night: [Stage, number, number][] = [
  ["Awake", 72, 80], ["Core", 80, 110], ["Deep", 110, 150], ["Core", 150, 185], ["REM", 185, 205],
  ["Core", 205, 240], ["Deep", 240, 268], ["Core", 268, 300], ["Awake", 300, 304], ["Core", 304, 340],
  ["REM", 340, 372], ["Core", 372, 410], ["Deep", 410, 425], ["Core", 425, 455], ["REM", 455, 490],
  ["Awake", 490, 494], ["Core", 494, 512], ["REM", 512, 520], ["Awake", 520, 528],
]

const row = (stage: Stage) => stages.findIndex((s) => s.name === stage)
const clock = (minutes: number) => {
  const h = (22 + Math.floor(minutes / 60)) % 24
  return `${h % 12 || 12}:${String(minutes % 60).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`
}
const duration = (minutes: number) => (minutes >= 60 ? `${Math.floor(minutes / 60)} hr ${minutes % 60} min` : `${minutes} min`)
const totals = Object.fromEntries(
  stages.map((s) => [s.name, night.filter(([stage]) => stage === s.name).reduce((sum, [, a, b]) => sum + b - a, 0)])
) as Record<Stage, number>
const asleep = totals.Deep + totals.Core + totals.REM

type Api = { value: (dim: number) => number; coord: (point: number[]) => number[]; size: (point: number[]) => number[] }

export default function SleepStagesChart() {
  const option = React.useCallback((theme: ChartTheme) => {
    const paint = stages.map((s) => theme.colors[s.color])
    return {
      ...baseOption(theme),
      grid: { left: 44, right: 8, top: 4, bottom: 24 },
      xAxis: {
        type: "value",
        min: 60,
        max: 540,
        interval: 120,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, formatter: (m: number) => clock(m).replace(":00", "") },
        splitLine: { lineStyle: { color: theme.colors.grid, type: [2, 3] } },
      },
      yAxis: {
        type: "category",
        data: stages.map((s) => s.name),
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, align: "left", margin: 44 },
        splitLine: { show: true, lineStyle: { color: theme.colors.grid } },
      },
      series: [
        {
          // Hairlines that join one stage to the next, shaded from one color to the other.
          id: "transitions",
          type: "custom",
          silent: true,
          data: night.slice(1).map(([stage, start], i) => [start, row(night[i]![0]), row(stage)]),
          renderItem: (_: unknown, api: Api) => {
            const from = api.coord([api.value(0), api.value(1)])
            const to = api.coord([api.value(0), api.value(2)])
            return {
              type: "line",
              shape: { x1: from[0], y1: from[1], x2: to[0], y2: to[1] },
              style: {
                lineWidth: 1,
                stroke: new echarts.graphic.LinearGradient(from[0]!, from[1]!, to[0]!, to[1]!, [
                  { offset: 0, color: paint[api.value(1)]! },
                  { offset: 1, color: paint[api.value(2)]! },
                ], true),
                opacity: 0.7,
              },
            }
          },
        },
        {
          id: "stages",
          type: "custom",
          data: night.map(([stage, start, end]) => [start, end, row(stage)]),
          renderItem: (_: unknown, api: Api) => {
            const start = api.coord([api.value(0), api.value(2)])
            const end = api.coord([api.value(1), api.value(2)])
            const height = api.size([0, 1])[1]! * 0.62
            return {
              type: "rect",
              shape: { x: start[0], y: start[1]! - height / 2, width: Math.max(end[0]! - start[0]!, 3), height, r: 4 },
              style: { fill: paint[api.value(2)] },
            }
          },
        },
      ],
    }
  }, [])

  return (
    <ChartFrame
      title="Sleep stages, Thursday night"
      summary={`Asleep ${duration(asleep)}, from ${clock(80)} to ${clock(528)}. Deep ${duration(totals.Deep)}, Core ${duration(totals.Core)}, REM ${duration(totals.REM)}, awake ${duration(totals.Awake)}.`}
      table={{ columns: ["Stage", "From", "To"], rows: night.map(([stage, a, b]) => [stage, clock(a), clock(b)]) }}
      style={{ width: "100%", maxWidth: 560 }}
    >
      <ChartHeader
        eyebrow="Time asleep"
        value={
          <>
            {Math.floor(asleep / 60)}
            <span className="mb-chart-unit"> hr </span>
            {asleep % 60}
          </>
        }
        unit="min"
        detail="Nov 12–13, 2026"
      />
      <EChart option={option} height={200} />
      <ChartLegend
        items={[...stages].reverse().map((s) => ({ label: s.name, color: `var(--mb-chart-${s.color})`, value: duration(totals[s.name]), shape: "bar" }))}
      />
    </ChartFrame>
  )
}

For a value that drifts slowly, like resting heart rate, show each day as a faint dot and the 7-day average as a line. A shaded band marks the typical range and a dashed line the average, so a reading outside the band stands out.

Average
58BPM
Aug 17–Nov 14, 2026
  • 7-day average
  • Daily
  • Typical range, 55–60
  • Average
Resting heart rate, last 90 days Your resting heart rate averaged 58 BPM and fell from about 60 to 57 BPM over 90 days. Your typical range is 55 to 60 BPM.
Resting heart rate, last 90 days
DateResting (BPM)7-day average
Aug 235960.4
Aug 305859.3
Sep 65958.1
Sep 136159.4
Sep 205958
Sep 275957.4
Oct 45858.4
Oct 115557
Oct 185656.7
Oct 255657.9
Nov 15455.9
Nov 85755.9
"use client"

import * as React from "react"
import { alpha, baseOption, categoryAxis, ChartFrame, ChartHeader, ChartLegend, EChart, valueAxis, type ChartTheme, type Scrub } from "@/components/charts"

/** Resting heart rate for the 90 days to November 14, 2026, drifting down as fitness improves. */
const days = Array.from({ length: 90 }, (_, i) => {
  const date = new Date(2026, 7, 17 + i)
  const bpm = Math.round(59.5 - i * 0.045 + Math.sin(i * 1.7) * 1.6 + Math.sin(i * 0.31) * 1.1 + (i % 7 === 5 ? 1.5 : 0))
  return { date, bpm }
})
const weekly = days.map((_, i) => {
  const window = days.slice(Math.max(0, i - 6), i + 1)
  return +(window.reduce((sum, d) => sum + d.bpm, 0) / window.length).toFixed(1)
})
const mean = days.reduce((sum, d) => sum + d.bpm, 0) / days.length
const typical = [Math.round(mean - 2.5), Math.round(mean + 2.5)] as const
const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" })
const labels = days.map((d) => (d.date.getDate() === 1 ? d.date.toLocaleDateString("en-US", { month: "short" }) : ""))

export default function RestingHeartRateTrend() {
  const [scrub, setScrub] = React.useState<Scrub | null>(null)

  const option = React.useCallback(
    (theme: ChartTheme) => ({
      ...baseOption(theme),
      grid: { left: 0, right: 30, top: 8, bottom: 24 },
      xAxis: categoryAxis(theme, labels, {
        boundaryGap: false,
        axisLabel: { color: theme.colors.axis, fontSize: theme.fontSize, interval: (i: number) => labels[i] !== "", align: "left" },
        splitLine: { show: true, interval: (i: number) => labels[i] !== "", lineStyle: { color: theme.colors.grid, type: [2, 3] } },
      }),
      yAxis: valueAxis(theme, { min: 50, max: 66, interval: 4 }),
      series: [
        {
          id: "days",
          type: "scatter",
          data: days.map((d) => d.bpm),
          symbolSize: 4,
          itemStyle: { color: alpha(theme.colors.pink, 0.45) },
          emphasis: { disabled: true },
          silent: true,
        },
        {
          id: "average",
          type: "line",
          data: weekly,
          smooth: 0.35,
          showSymbol: false,
          symbol: "circle",
          symbolSize: 8,
          lineStyle: { color: theme.colors.pink, width: 2.5, cap: "round" },
          itemStyle: { color: theme.colors.pink, borderColor: theme.colors.bg, borderWidth: 2 },
          emphasis: { scale: 1, lineStyle: { width: 2.5 } },
          markArea: {
            silent: true,
            itemStyle: { color: alpha(theme.colors.pink, theme.dark ? 0.12 : 0.08) },
            data: [[{ yAxis: typical[0] }, { yAxis: typical[1] }]],
          },
          markLine: {
            silent: true,
            symbol: "none",
            label: { show: false },
            lineStyle: { color: theme.colors.label3, type: [3, 3], width: 1 },
            data: [{ yAxis: +mean.toFixed(1) }],
          },
        },
      ],
    }),
    []
  )

  const index = scrub?.index
  return (
    <ChartFrame
      title="Resting heart rate, last 90 days"
      summary={`Your resting heart rate averaged ${Math.round(mean)} BPM and fell from about ${Math.round(weekly[6]!)} to ${Math.round(weekly.at(-1)!)} BPM over 90 days. Your typical range is ${typical[0]} to ${typical[1]} BPM.`}
      table={{
        columns: ["Date", "Resting (BPM)", "7-day average"],
        rows: days.filter((_, i) => i % 7 === 6).map((d, i) => [short.format(d.date), d.bpm, weekly[i * 7 + 6]!]),
      }}
      style={{ width: "100%", maxWidth: 560 }}
    >
      <ChartHeader
        eyebrow={index === undefined ? "Average" : "7-day average"}
        value={index === undefined ? Math.round(mean) : weekly[index]}
        unit="BPM"
        detail={index === undefined ? "Aug 17–Nov 14, 2026" : short.format(days[index]!.date)}
      />
      <EChart
        option={option}
        height={200}
        onScrub={setScrub}
        scrub={{
          label: "Resting heart rate",
          count: days.length,
          valueText: (i) => `${weekly[i]} BPM 7-day average, ${short.format(days[i]!.date)}`,
        }}
      />
      <ChartLegend
        items={[
          { label: "7-day average", color: "var(--mb-pink)", shape: "line" },
          { label: "Daily", color: "color-mix(in oklch, var(--mb-pink) 50%, transparent)" },
          { label: `Typical range, ${typical[0]}–${typical[1]}`, color: "color-mix(in oklch, var(--mb-pink) 20%, transparent)", shape: "bar" },
          { label: "Average", color: "var(--mb-label-tertiary)", shape: "dashed" },
        ]}
      />
    </ChartFrame>
  )
}

Highlights

A highlight leads with a sentence, then shows the two numbers it compares as capsules, each labeled with its value and period. These are plain HTML and CSS; a canvas adds nothing for two bars.

StepsToday

You’re averaging more steps this year than you did last year.

Steps: 2026 compared with 2025 2026: 8,214 steps/day. 2025: 7,102 steps/day
Resting Heart RateYesterday

Your resting heart rate was lower this month than last month.

Resting Heart Rate: November compared with October November: 55 BPM. October: 58 BPM
import { Card } from "macbetter/card"
import { FootprintsIcon, HeartIcon } from "lucide-react"
import { ChartFrame } from "@/components/charts"

const highlights = [
  {
    category: "Steps",
    icon: FootprintsIcon,
    color: "var(--mb-orange)",
    when: "Today",
    headline: "You’re averaging more steps this year than you did last year.",
    unit: "steps/day",
    bars: [
      { label: "2026", value: 8214 },
      { label: "2025", value: 7102 },
    ],
  },
  {
    category: "Resting Heart Rate",
    icon: HeartIcon,
    color: "var(--mb-pink)",
    when: "Yesterday",
    headline: "Your resting heart rate was lower this month than last month.",
    unit: "BPM",
    bars: [
      { label: "November", value: 55 },
      { label: "October", value: 58 },
    ],
  },
]

/** Health's Highlights: a sentence, then the two numbers it compares, as capsules. */
export default function HealthHighlights() {
  return (
    <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 16, width: "100%", maxWidth: 680 }}>
      {highlights.map((h) => {
        const max = Math.max(...h.bars.map((b) => b.value))
        return (
          <Card key={h.category} style={{ display: "grid", gap: 12, alignContent: "start" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 6, color: h.color, font: "var(--mb-text-subheadline)", fontWeight: 600 }}>
              <h.icon aria-hidden="true" style={{ width: "1.1em", height: "1.1em" }} fill="currentColor" strokeWidth={1.5} />
              {h.category}
              <span style={{ marginInlineStart: "auto", color: "var(--mb-label-secondary)", fontWeight: 400 }}>{h.when}</span>
            </div>
            <p style={{ margin: 0, font: "var(--mb-text-headline)" }}>{h.headline}</p>
            <ChartFrame
              title={`${h.category}: ${h.bars[0]!.label} compared with ${h.bars[1]!.label}`}
              summary={h.bars.map((b) => `${b.label}: ${b.value.toLocaleString("en-US")} ${h.unit}`).join(". ")}
              style={{ gap: 10 }}
            >
              {h.bars.map((bar, i) => (
                <div key={bar.label} aria-hidden="true" style={{ display: "grid", gap: 4 }}>
                  <div style={{ display: "flex", alignItems: "baseline", gap: 4, fontVariantNumeric: "tabular-nums" }}>
                    <span style={{ font: "var(--mb-text-title-3)", fontFamily: "var(--mb-font-rounded)", color: i === 0 ? h.color : "var(--mb-label)" }}>
                      {bar.value.toLocaleString("en-US")}
                    </span>
                    <span style={{ color: "var(--mb-label-secondary)", font: "var(--mb-text-footnote)", fontWeight: 600 }}>{h.unit}</span>
                  </div>
                  <div
                    style={{
                      display: "flex",
                      alignItems: "center",
                      width: `${(bar.value / max) * 100}%`,
                      height: 26,
                      paddingInline: 10,
                      borderRadius: 7,
                      background: i === 0 ? h.color : "var(--mb-fill)",
                      color: i === 0 ? `oklch(from ${h.color} 0.26 0.06 h)` : "var(--mb-label)",
                      font: "var(--mb-text-footnote)",
                      fontWeight: 600,
                    }}
                  >
                    {bar.label}
                  </div>
                </div>
              ))}
            </ChartFrame>
          </Card>
        )
      })}
    </div>
  )
}

Accessibility

  • Each chart is a figure named by its title and described by a one-sentence summary, with every value in a visually hidden table.
  • A scrubbable chart is a slider. Tab to it, then use the arrow keys, Page Up, Page Down, Home and End. It reads each point as a value and a date, like "9,832 steps, Mon, Nov 9".
  • Color is never the only key. Sleep stages are named in the legend with their durations, and heart rate says "Range" in the header.
  • With Reduce Motion on, charts appear without the grow-in.