A multi-metric vitals trend chart (BP, heart rate, temperature) with per-metric y-scales, a shaded reference range band, out-of-range point flags, and a line that draws in on first view.
npx shadcn@latest add @paragon/vitals-trend"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface VitalSeries {
key: string;
label: string;
unit: string;
color: string;
data: number[];
/** Reference band [low, high] drawn behind the line. */
range?: [number, number];
}
export interface VitalsTrendProps extends React.ComponentProps<"div"> {
labels?: string[];
series?: VitalSeries[];
/** Which series key to plot. Defaults to the first. */
metric?: string;
height?: number;
/** Draw the reference range band. */
showRange?: boolean;
static?: boolean;
}
const DEFAULT_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const DEFAULT_SERIES: VitalSeries[] = [
{
key: "systolic",
label: "Systolic BP",
unit: "mmHg",
color: "oklch(0.585 0.17 260)",
data: [128, 134, 131, 126, 138, 142, 133],
range: [90, 130],
},
{
key: "heartRate",
label: "Heart rate",
unit: "bpm",
color: "oklch(0.62 0.18 25)",
data: [72, 76, 74, 70, 81, 78, 73],
range: [60, 100],
},
{
key: "temperature",
label: "Temperature",
unit: "°F",
color: "oklch(0.72 0.14 60)",
data: [98.4, 98.7, 99.1, 98.9, 100.2, 99.6, 98.8],
range: [97, 99],
},
];
/**
* A multi-metric vitals trend chart. A segmented control switches between
* BP / HR / temp; each metric plots against its own y-scale with a reference
* range band shaded behind. The line draws in on first view via
* stroke-dashoffset and out-of-range points are marked. Responsive via
* ResizeObserver; reduced motion renders the final line immediately.
*/
export function VitalsTrend({
labels = DEFAULT_LABELS,
series = DEFAULT_SERIES,
metric,
height = 200,
showRange = true,
static: isStatic = false,
className,
...props
}: VitalsTrendProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -48px 0px" });
const active = series.find((s) => s.key === metric) ?? series[0];
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
obs.observe(el);
return () => obs.disconnect();
}, []);
const animate = !isStatic && !reduced;
const drawn = !animate || inView;
const n = labels.length;
const pad = { top: 12, right: 12, bottom: 24, left: 40 };
const innerW = Math.max(width - pad.left - pad.right, 0);
const plotBottom = height - pad.bottom;
const innerH = plotBottom - pad.top;
const values = active.data;
const band = active.range;
const lo = Math.min(...values, band ? band[0] : Infinity);
const hi = Math.max(...values, band ? band[1] : -Infinity);
const span = hi - lo || 1;
const yMin = lo - span * 0.15;
const yMax = hi + span * 0.15;
const xFor = (i: number) =>
pad.left + (n > 1 ? (i / (n - 1)) * innerW : innerW / 2);
const yFor = (v: number) =>
plotBottom - ((v - yMin) / (yMax - yMin)) * innerH;
const path = values
.map((v, i) => `${i === 0 ? "M" : "L"}${xFor(i)} ${yFor(v)}`)
.join(" ");
const yTicks = [yMin, (yMin + yMax) / 2, yMax];
return (
<div
data-slot="vitals-trend"
className={cn(
"w-full max-w-lg rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between gap-3">
<div>
<h3 className="text-sm font-medium">{active.label}</h3>
<p className="text-xs text-muted-foreground">
7-day trend ·{" "}
<span className="tabular-nums">
latest {values[values.length - 1]} {active.unit}
</span>
</p>
</div>
</div>
{metric === undefined && (
<div
role="group"
aria-label="Metric"
className="mt-3 inline-flex rounded-lg bg-secondary p-0.5 text-xs"
>
{series.map((s) => (
<span
key={s.key}
className={cn(
"rounded-md px-2.5 py-1 font-medium",
s.key === active.key
? "bg-card text-foreground shadow-border"
: "text-muted-foreground",
)}
>
{s.label}
</span>
))}
</div>
)}
<div ref={ref} className="relative mt-3 w-full">
{width > 0 && (
<svg
role="img"
aria-label={`${active.label} over ${n} days in ${active.unit}`}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block"
>
{/* reference range band */}
{showRange && band && (
<rect
x={pad.left}
y={yFor(band[1])}
width={innerW}
height={Math.max(0, yFor(band[0]) - yFor(band[1]))}
rx={4}
fill={active.color}
fillOpacity={0.08}
/>
)}
{/* range edges */}
{showRange &&
band &&
band.map((b) => (
<line
key={b}
x1={pad.left}
x2={width - pad.right}
y1={yFor(b)}
y2={yFor(b)}
stroke={active.color}
strokeOpacity={0.25}
strokeDasharray="3 3"
/>
))}
{/* y labels */}
{yTicks.map((t) => (
<text
key={t}
x={pad.left - 8}
y={yFor(t)}
textAnchor="end"
dominantBaseline="central"
fontSize={10}
className="fill-muted-foreground tabular-nums"
>
{Math.round(t)}
</text>
))}
{/* x labels */}
{labels.map((l, i) => (
<text
key={l}
x={xFor(i)}
y={height - 8}
textAnchor="middle"
fontSize={10}
className="fill-muted-foreground"
>
{l}
</text>
))}
{/* line */}
<path
d={path}
fill="none"
stroke={active.color}
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={1}
style={{
strokeDashoffset: drawn ? 0 : 1,
transition: animate
? "stroke-dashoffset 700ms var(--ease-out)"
: undefined,
}}
/>
{/* points, out-of-range flagged */}
{values.map((v, i) => {
const out = band && (v < band[0] || v > band[1]);
return (
<circle
key={i}
cx={xFor(i)}
cy={yFor(v)}
r={out ? 3 : 2.25}
fill={out ? "var(--color-destructive)" : "var(--color-card)"}
stroke={out ? "var(--color-destructive)" : active.color}
strokeWidth={1.5}
style={{
opacity: drawn ? 1 : 0,
transition: animate
? `opacity 200ms var(--ease-out) ${300 + i * 40}ms`
: undefined,
}}
/>
);
})}
</svg>
)}
</div>
</div>
);
}