Healthcare

Vitals Monitor

A continuously sweeping SVG ECG trace with a live BPM headline and tabular vitals; the sweep pauses offscreen and freezes to a still trace under reduced motion.

Install

npx shadcn@latest add @paragon/vitals-monitor

vitals-monitor.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface VitalReading {
  label: string;
  value: number;
  unit: string;
  /** Reading tint. Defaults to "normal". */
  tone?: "normal" | "warning" | "critical";
}

export interface VitalsMonitorProps extends React.ComponentProps<"div"> {
  /** Beats per minute — sets the sweep tempo and the headline figure. */
  bpm?: number;
  /** Tabular vitals shown beside the trace. */
  readings?: VitalReading[];
  /** Trace stroke color. Defaults to the success token. */
  traceColor?: string;
  /** Freezes the sweep and renders a static trace. */
  static?: boolean;
}

const DEFAULT_READINGS: VitalReading[] = [
  { label: "SpO₂", value: 98, unit: "%" },
  { label: "Resp", value: 16, unit: "/min" },
  { label: "Temp", value: 98.6, unit: "°F" },
  { label: "NIBP", value: 118, unit: "/78" },
];

const toneText: Record<NonNullable<VitalReading["tone"]>, string> = {
  normal: "text-foreground",
  warning: "text-warning",
  critical: "text-destructive",
};

/**
 * One PQRST heartbeat sampled into a normalized polyline. Plotted across a
 * fixed viewBox and repeated three times so a single leftward sweep can loop
 * seamlessly (transform: translateX on the whole group, keyframes, linear —
 * the one place linear is allowed). A soft leading-edge highlight rides the
 * sweep. The loop pauses when scrolled offscreen via IntersectionObserver and
 * freezes under reduced motion, which renders a still trace instead.
 */
// Normalized PQRST morphology over one beat; y measured downward, R points up.
const BEAT: [number, number][] = [
  [0, 0],
  [0.12, 0],
  [0.16, 0.06], // P
  [0.2, 0],
  [0.3, 0],
  [0.33, 0.12], // Q
  [0.37, -1], // R
  [0.41, 0.28], // S
  [0.46, 0],
  [0.62, 0],
  [0.7, 0.34], // T
  [0.8, 0],
  [1, 0],
];

/** Tile `count` beats end-to-end into one continuous polyline path. */
function tiledPath(
  count: number,
  beatW: number,
  mid: number,
  amp: number,
): string {
  const pts: string[] = [];
  for (let k = 0; k < count; k++) {
    for (const [t, y] of BEAT) {
      const x = (k + t) * beatW;
      const yy = mid + y * amp;
      pts.push(`${pts.length === 0 ? "M" : "L"}${x.toFixed(2)} ${yy.toFixed(2)}`);
    }
  }
  return pts.join(" ");
}

export function VitalsMonitor({
  bpm = 72,
  readings = DEFAULT_READINGS,
  traceColor = "var(--color-success)",
  static: isStatic = false,
  className,
  ...props
}: VitalsMonitorProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const [inView, setInView] = React.useState(false);

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      { threshold: 0.15 },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const animate = !isStatic && !reducedMotion && inView;

  // One beat spans BEAT_W; three beats tile the sweep so a translate of one
  // beat width loops seamlessly. Slower bpm -> longer per-beat duration.
  const BEAT_W = 160;
  const H = 96;
  const mid = H * 0.52;
  const amp = H * 0.34;
  const tiled = tiledPath(4, BEAT_W, mid, amp);
  const beatSeconds = 60 / Math.max(30, Math.min(220, bpm));

  return (
    <div
      ref={rootRef}
      data-slot="vitals-monitor"
      className={cn(
        "flex w-full flex-col gap-4 overflow-hidden rounded-xl bg-card p-4 shadow-border sm:flex-row sm:items-stretch",
        className,
      )}
      {...props}
    >
      <style href="paragon-vitals-monitor" precedence="paragon">{`
        @keyframes paragon-vitals-sweep {
          from { transform: translateX(0); }
          to { transform: translateX(-${BEAT_W}px); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-paragon-vitals-sweep] { animation: none !important; }
        }
      `}</style>

      <div className="relative min-w-0 flex-1">
        <div className="mb-2 flex items-baseline gap-2">
          <span className="text-2xl leading-none font-semibold tabular-nums text-foreground">
            {bpm}
          </span>
          <span className="text-xs text-muted-foreground">BPM</span>
          <span
            aria-hidden
            className={cn(
              "ml-auto size-2 rounded-full bg-success",
              animate && "[animation:paragon-vitals-blip_var(--beat)_linear_infinite]",
            )}
            style={{ ["--beat" as string]: `${beatSeconds}s` }}
          />
          <style href="paragon-vitals-blip" precedence="paragon">{`
            @keyframes paragon-vitals-blip {
              0%, 70% { opacity: 0.35; }
              12% { opacity: 1; }
            }
            @media (prefers-reduced-motion: reduce) {
              [data-slot="vitals-monitor"] [style*="--beat"] { animation: none !important; opacity: 1; }
            }
          `}</style>
        </div>
        <div className="relative h-24 overflow-hidden rounded-lg bg-secondary/40">
          {/* baseline grid */}
          <svg
            aria-hidden
            className="absolute inset-0 h-full w-full text-foreground/[0.05]"
            preserveAspectRatio="none"
            viewBox={`0 0 ${BEAT_W} ${H}`}
          >
            {[0.25, 0.5, 0.75].map((f) => (
              <line
                key={f}
                x1={0}
                x2={BEAT_W}
                y1={H * f}
                y2={H * f}
                stroke="currentColor"
              />
            ))}
          </svg>
          <svg
            role="img"
            aria-label={`Live ECG trace at ${bpm} beats per minute`}
            className="absolute inset-0 h-full w-full"
            preserveAspectRatio="none"
            viewBox={`0 0 ${BEAT_W} ${H}`}
          >
            <g
              data-paragon-vitals-sweep
              style={
                animate
                  ? {
                      animation: `paragon-vitals-sweep ${beatSeconds}s linear infinite`,
                    }
                  : undefined
              }
            >
              <path
                d={tiled}
                fill="none"
                stroke={traceColor}
                strokeWidth={1.75}
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
            </g>
          </svg>
          {/* leading-edge glow follows the freshest sample on the right */}
          <div
            aria-hidden
            className="pointer-events-none absolute inset-y-0 right-0 w-16"
            style={{
              background:
                "linear-gradient(to right, transparent, var(--color-card))",
            }}
          />
        </div>
      </div>

      <div className="grid shrink-0 grid-cols-2 gap-x-5 gap-y-2.5 sm:w-40 sm:grid-cols-1 sm:content-center">
        {readings.map((r) => (
          <div key={r.label} className="flex items-baseline justify-between gap-3">
            <span className="text-xs text-muted-foreground">{r.label}</span>
            <span className="flex items-baseline gap-0.5">
              <span
                className={cn(
                  "text-sm font-medium tabular-nums",
                  toneText[r.tone ?? "normal"],
                )}
              >
                {r.value}
              </span>
              <span className="text-[11px] text-muted-foreground">{r.unit}</span>
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}