Charts
Health
Bars with rounded tops, ranges as capsules, a night of sleep in four lanes, and a header that follows your finger.
| Period | Steps |
|---|---|
| Sun, Nov 8 | 6,412 |
| Mon, Nov 9 | 9,832 |
| Tue, Nov 10 | 7,120 |
| Wed, Nov 11 | 11,204 |
| Thu, Nov 12 | 8,530 |
| Fri, Nov 13 | 7,980 |
| Today | 6,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,6MandYin 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.
| Time | Lowest (BPM) | Highest (BPM) |
|---|---|---|
| 12–1 AM | 54 | 61 |
| 1–2 AM | 52 | 58 |
| 2–3 AM | 53 | 60 |
| 3–4 AM | 55 | 62 |
| 4–5 AM | 53 | 59 |
| 5–6 AM | 56 | 63 |
| 6–7 AM | 58 | 74 |
| 7–8 AM | 66 | 112 |
| 8–9 AM | 72 | 104 |
| 9–10 AM | 70 | 88 |
| 10–11 AM | 68 | 92 |
| 11–12 PM | 71 | 96 |
| 12–1 PM | 74 | 102 |
| 1–2 PM | 69 | 94 |
| 2–3 PM | 67 | 90 |
| 3–4 PM | 70 | 98 |
| 4–5 PM | 88 | 142 |
| 5–6 PM | 96 | 148 |
| 6–7 PM | 78 | 118 |
| 7–8 PM | 72 | 96 |
| 8–9 PM | 68 | 88 |
| 9–10 PM | 64 | 80 |
| 10–11 PM | 60 | 72 |
| 11–12 AM | 57 | 64 |
"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.
- Awake24 min
- REM1 hr 35 min
- Core4 hr 14 min
- Deep1 hr 23 min
| Stage | From | To |
|---|---|---|
| Awake | 11:12 PM | 11:20 PM |
| Core | 11:20 PM | 11:50 PM |
| Deep | 11:50 PM | 12:30 AM |
| Core | 12:30 AM | 1:05 AM |
| REM | 1:05 AM | 1:25 AM |
| Core | 1:25 AM | 2:00 AM |
| Deep | 2:00 AM | 2:28 AM |
| Core | 2:28 AM | 3:00 AM |
| Awake | 3:00 AM | 3:04 AM |
| Core | 3:04 AM | 3:40 AM |
| REM | 3:40 AM | 4:12 AM |
| Core | 4:12 AM | 4:50 AM |
| Deep | 4:50 AM | 5:05 AM |
| Core | 5:05 AM | 5:35 AM |
| REM | 5:35 AM | 6:10 AM |
| Awake | 6:10 AM | 6:14 AM |
| Core | 6:14 AM | 6:32 AM |
| REM | 6:32 AM | 6:40 AM |
| Awake | 6:40 AM | 6: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>
)
}Trends
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.
- 7-day average
- Daily
- Typical range, 55–60
- Average
| Date | Resting (BPM) | 7-day average |
|---|---|---|
| Aug 23 | 59 | 60.4 |
| Aug 30 | 58 | 59.3 |
| Sep 6 | 59 | 58.1 |
| Sep 13 | 61 | 59.4 |
| Sep 20 | 59 | 58 |
| Sep 27 | 59 | 57.4 |
| Oct 4 | 58 | 58.4 |
| Oct 11 | 55 | 57 |
| Oct 18 | 56 | 56.7 |
| Oct 25 | 56 | 57.9 |
| Nov 1 | 54 | 55.9 |
| Nov 8 | 57 | 55.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.
You’re averaging more steps this year than you did last year.
Your resting heart rate was lower this month than last month.
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
figurenamed 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,HomeandEnd. 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.