Healthcare

Lab Result Range

A lab value plotted on a reference-range bar; the marker eases into position on view and pulses once when out of range, tinted low, normal, or high.

Install

npx shadcn@latest add @paragon/lab-result-range

lab-result-range.tsx

"use client";

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

export interface LabResultRangeProps extends React.ComponentProps<"div"> {
  /** Analyte name, e.g. "Glucose, fasting". */
  label: string;
  /** Measured value. */
  value: number;
  unit: string;
  /** Reference interval [low, high] — the normal band. */
  low: number;
  high: number;
  /** Scale bounds. Defaults to widen the reference band by 40% each side. */
  min?: number;
  max?: number;
  /** Renders the marker in place with no travel or pulse. */
  static?: boolean;
}

type Status = "low" | "normal" | "high";

const statusToken: Record<Status, { text: string; dot: string; bg: string }> = {
  low: {
    text: "text-warning",
    dot: "bg-warning",
    bg: "bg-warning/15",
  },
  normal: {
    text: "text-success",
    dot: "bg-success",
    bg: "bg-success/12",
  },
  high: {
    text: "text-destructive",
    dot: "bg-destructive",
    bg: "bg-destructive/15",
  },
};

const statusLabel: Record<Status, string> = {
  low: "Low",
  normal: "In range",
  high: "High",
};

/**
 * A single lab value plotted on a reference-range bar. The normal band is
 * tinted between the low/high thresholds; on first view the value marker
 * eases from the band's center into its true position (transform only). If
 * the value lands out of range, the marker pulses once via a self-removing
 * keyframe, then rests. Reduced motion places the marker directly.
 */
export function LabResultRange({
  label,
  value,
  unit,
  low,
  high,
  min,
  max,
  static: isStatic = false,
  className,
  ...props
}: LabResultRangeProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const animate = !isStatic && !reducedMotion;
  const settled = !animate || inView;

  const span = high - low;
  const lo = min ?? low - span * 0.4;
  const hi = max ?? high + span * 0.4;
  const clamp = (n: number) => Math.max(0, Math.min(1, n));
  const pct = (n: number) => clamp((n - lo) / (hi - lo)) * 100;

  const status: Status = value < low ? "low" : value > high ? "high" : "normal";
  const token = statusToken[status];
  const outOfRange = status !== "normal";

  const markerPct = pct(value);
  const restPos = settled ? markerPct : 50;

  return (
    <div
      ref={ref}
      data-slot="lab-result-range"
      className={cn(
        "flex w-full flex-col gap-3 rounded-xl bg-card p-4 shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-lab-result-range" precedence="paragon">{`
        @keyframes paragon-lab-pulse {
          0% { transform: translateX(-50%) scale(1); }
          40% { transform: translateX(-50%) scale(1.55); }
          100% { transform: translateX(-50%) scale(1); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-paragon-lab-marker] { animation: none !important; }
        }
      `}</style>

      <div className="flex items-baseline justify-between gap-3">
        <span className="text-sm font-medium text-foreground">{label}</span>
        <span className="flex items-baseline gap-1.5">
          <span className={cn("text-sm font-semibold tabular-nums", token.text)}>
            {value}
          </span>
          <span className="text-xs text-muted-foreground">{unit}</span>
          <span
            className={cn(
              "ml-1.5 rounded-full px-1.5 py-0.5 text-[11px] font-medium",
              token.bg,
              token.text,
            )}
          >
            {statusLabel[status]}
          </span>
        </span>
      </div>

      <div className="relative">
        <div className="relative h-2 overflow-hidden rounded-full bg-secondary">
          {/* normal band */}
          <div
            aria-hidden
            className="absolute inset-y-0 bg-success/25"
            style={{ left: `${pct(low)}%`, right: `${100 - pct(high)}%` }}
          />
        </div>
        {/* marker */}
        <div
          aria-hidden
          data-paragon-lab-marker
          className={cn(
            "absolute top-1/2 size-3.5 rounded-full border-2 border-card",
            token.dot,
            !settled && "opacity-0",
          )}
          style={{
            left: `${restPos}%`,
            marginTop: -7,
            transform: "translateX(-50%)",
            transition: animate
              ? "left 640ms var(--ease-out), opacity 200ms var(--ease-out)"
              : undefined,
            // Pulse fires once after the marker arrives at an out-of-range spot.
            animation:
              animate && settled && outOfRange
                ? "paragon-lab-pulse 520ms var(--ease-out) 700ms 1"
                : undefined,
          }}
        />
      </div>

      <div className="flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
        <span>{lo.toLocaleString("en-US")}</span>
        <span>
          Ref {low}{high} {unit}
        </span>
        <span>{hi.toLocaleString("en-US")}</span>
      </div>
    </div>
  );
}