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.
npx shadcn@latest add @paragon/vitals-monitor"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>
);
}