Healthcare

Vitals Trend

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.

Install

npx shadcn@latest add @paragon/vitals-trend

vitals-trend.tsx

"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>
  );
}