Skip to content
All components

Observation Panel

Stable

Observation[]Clinical data · Clinical

Renders a set of Observation resources as a results list: value, units, reference range, and interpretation. The interpretation logic is the entire point. An interpretation stated in the payload always wins; absent one, it is derived only by comparing the value to its own reference range; with neither, the result reads “Not interpreted” rather than “Normal”. Silently defaulting an uninterpreted result to normal is how a UI manufactures false reassurance.

pnpm dlx shadcn@latest add @oxygenui/vitals-panel

First install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/vitals-panel.json directly.

Preview

Every state, switchable.

Live · synthetic data

1 critical result in this panel.

Chemistry panel
TestResultReferenceInterpretation
Potassium
6.8 mmol/L3.5 – 5.1 mmol/LCritical high
Blood pressure
2 partsHigh
Systolic168 mmHg90 – 130 mmHgHigh
Diastolic82 mmHg60 – 85 mmHgNormal
Hemoglobin
10.2 g/dL12 – 15.5 g/dLLow
Glucose
Corrected
156 mg/dL70 – 100 mg/dLHigh
Heart rate
72 beats/min60 – 100 beats/minNormal
TSH
Preliminary
5.9 mIU/L0.4 – 4 mIU/LHigh
Ferritin
43 ng/mLNot interpreted
White blood cell count
White blood cell count, Not knownSpecimen hemolyzed — recollectNot interpreted

Severity reaches the reader three ways: badge, left rule, and a live-region announcement.

Multi-component (blood pressure)Critical high and lowHigh and lowNormalNot interpretedNo value (dataAbsentReason)PreliminaryAmended and correctedEmptyLoading

Usage

Props are the FHIR resource.

import { ObservationPanel } from "@/components/oxygen/vitals-panel";

export function Results({ bundle }: { bundle: Bundle<Observation> }) {
  const observations =
    bundle.entry?.map((entry) => entry.resource!) ?? [];

  return (
    <ObservationPanel
      observations={observations}
      label="Chemistry panel"
      onSelect={(observation) => openDetail(observation.id)}
    />
  );
}

Dependencies

  • @oxygenui-design/fhir@^0.1.0
  • lucide-react
  • clsx
  • tailwind-merge
Observation Panel props
PropTypeDefault
observations

FHIR R4 Observation resources, in the order they should be read.

Observation[] | undefined
emptyMessage

Shown when `observations` is an empty array.

string | undefined"No results in this period."
hideReferenceRange

Hide the reference-range column — useful in narrow or patient-facing layouts.

boolean | undefinedfalse
label

Accessible name for the results table.

string | undefined"Observations"
loading

Renders the skeleton state.

boolean | undefinedfalse
loadingRows

Number of skeleton rows while loading.

number | undefined4
onSelect

Called when a row is activated. Omit to render non-interactive rows.

((observation: Observation) => void) | undefined

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • For lab panels, vitals, and any grouped set of Observation resources.
  • With hideReferenceRange on patient-facing surfaces where a range would confuse more than inform.
  • Sorted with the most clinically urgent results first — the component preserves your order.

Guardrails

Don't

03
  • For a single headline value. Use a metric card so the value is not buried in a table.
  • For trending over time. This is a point-in-time panel, not a chart.
  • As a substitute for critical-result notification. A visible badge is not an alerting pathway.

Quality

What was tested, and what is still missing.

Table semantics
Real table markup with scoped column headers and an accessible caption.
Critical announcement
A live region states the critical count before the table is read, so severity is known up front rather than discovered on row seven.
Never color alone
Every interpretation carries an icon and a text label. Critical rows add an inset rule — a second structural cue that survives grayscale and forced colors.
Row activation
When onSelect is provided, rows are focusable and respond to Enter and Space with a visible focus ring.
Multi-part results
Blood pressure and other component-carried readings render each part as its own row, separately valued and separately flagged. The parent escalates to its worst component so a raised systolic is never hidden behind a silent panel.

Open gaps

Known limitations

03
  • Renders referenceRange[0] only. Age- and sex-specific ranges are not yet selected by context.
  • Component reference ranges are read from referenceRange[0], same as the parent.
  • No built-in unit conversion. Values render in the units supplied.

Source

Exactly what lands in your repository.

Read directly from the published registry, so this can never drift from what the CLI installs.

Show source494 lines
"use client";

/**
 * ObservationPanel — renders a set of FHIR R4 `Observation` resources as a
 * results list: value, units, reference range, and interpretation.
 *
 * The interpretation logic is the entire point of this component, and it is
 * conservative by design:
 *
 *   - An interpretation stated in the payload always wins.
 *   - If none is stated, it is derived ONLY by comparing the value to its own
 *     reference range. Nothing else is inferred.
 *   - With neither, the result reads "Not interpreted" — never "Normal".
 *     Silently defaulting an uninterpreted result to normal is how a UI
 *     manufactures false reassurance.
 *
 * Severity is carried by icon, text label, and position — never by color
 * alone. Verify by viewing in forced-colors mode: every status must still be
 * distinguishable.
 */

import * as React from "react";
import {
  ArrowDown,
  ArrowUp,
  Check,
  CircleAlert,
  CircleHelp,
  FileWarning,
  Minus,
  PencilLine,
} from "lucide-react";
import {
  codeableText,
  formatComponentValue,
  formatObservationValue,
  formatReferenceRange,
  getComponentInterpretation,
  getPanelInterpretation,
  INTERPRETATION_LABEL,
  isCorrected,
  isCritical,
  isProvisional,
  type Interpretation,
  type Observation,
  type ObservationComponent,
} from "@oxygenui-design/fhir";
import { AbsentValue } from "@/components/oxygen/absent-value";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// Interpretation → presentation
// ---------------------------------------------------------------------------

const INTERPRETATION_ICON: Record<Interpretation, React.ComponentType<{ className?: string }>> = {
  "critical-high": CircleAlert,
  "critical-low": CircleAlert,
  high: ArrowUp,
  low: ArrowDown,
  abnormal: CircleAlert,
  normal: Check,
  unknown: CircleHelp,
};

/**
 * Class names are written out in full, never assembled from a variable.
 * Tailwind resolves classes by scanning source text, so a template literal
 * like `text-[var(--ox-status-${token})]` produces no CSS at all — the badge
 * renders unstyled and severity silently disappears. Keep these literal.
 */
const INTERPRETATION_CLASS: Record<Interpretation, { badge: string; value: string }> = {
  "critical-high": {
    badge:
      "border-[var(--ox-status-critical-border)] bg-[var(--ox-status-critical-bg)] text-[var(--ox-status-critical)]",
    value: "text-[var(--ox-status-critical)]",
  },
  "critical-low": {
    badge:
      "border-[var(--ox-status-critical-border)] bg-[var(--ox-status-critical-bg)] text-[var(--ox-status-critical)]",
    value: "text-[var(--ox-status-critical)]",
  },
  high: {
    badge:
      "border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
    value: "text-[var(--ox-status-high)]",
  },
  low: {
    badge:
      "border-[var(--ox-status-low-border)] bg-[var(--ox-status-low-bg)] text-[var(--ox-status-low)]",
    value: "text-[var(--ox-status-low)]",
  },
  abnormal: {
    badge:
      "border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)] text-[var(--ox-status-high)]",
    value: "text-[var(--ox-status-high)]",
  },
  normal: {
    badge:
      "border-[var(--ox-status-normal-border)] bg-[var(--ox-status-normal-bg)] text-[var(--ox-status-normal)]",
    value: "text-[var(--ox-status-normal)]",
  },
  unknown: {
    badge:
      "border-[var(--ox-status-unknown-border)] bg-[var(--ox-status-unknown-bg)] text-[var(--ox-status-unknown)]",
    value: "text-[var(--ox-status-unknown)]",
  },
};

// `onSelect` is omitted from the DOM attributes deliberately: React's native
// onSelect fires on text selection, which is not what a row activation means.
// Ours takes the Observation that was chosen.
export interface ObservationPanelProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "onSelect"
> {
  /** FHIR R4 Observation resources, in the order they should be read. */
  observations: Observation[] | undefined;
  /** Accessible name for the results table. */
  label?: string;
  /** Hide the reference-range column — useful in narrow or patient-facing layouts. */
  hideReferenceRange?: boolean;
  /** Renders the skeleton state. */
  loading?: boolean;
  /** Number of skeleton rows while loading. */
  loadingRows?: number;
  /** Shown when `observations` is an empty array. */
  emptyMessage?: string;
  /** Called when a row is activated. Omit to render non-interactive rows. */
  onSelect?: (observation: Observation) => void;
}

export function ObservationPanel({
  observations,
  label = "Observations",
  hideReferenceRange = false,
  loading = false,
  loadingRows = 4,
  emptyMessage = "No results in this period.",
  onSelect,
  className,
  ...props
}: ObservationPanelProps) {
  if (loading) {
    return <ObservationPanelSkeleton rows={loadingRows} className={className} {...props} />;
  }

  if (!observations?.length) {
    return (
      <div
        className={cn(
          "rounded-[var(--ox-radius-lg)] border border-dashed border-[var(--ox-border-strong)]",
          "bg-[var(--ox-bg-subtle)] px-6 py-10 text-center",
          className,
        )}
        {...props}
      >
        <p className="text-[length:var(--ox-text-base)] text-[var(--ox-text-muted)]">
          {emptyMessage}
        </p>
      </div>
    );
  }

  const criticalCount = observations.filter((o) => isCritical(getPanelInterpretation(o))).length;

  return (
    <div
      className={cn(
        "overflow-hidden rounded-[var(--ox-radius-lg)] border border-[var(--ox-border)]",
        "bg-[var(--ox-surface)]",
        className,
      )}
      {...props}
    >
      {/* Announced to assistive tech before the table is read, so a critical
          result is known up front rather than discovered on row seven. */}
      {criticalCount > 0 && (
        <p className="sr-only" role="status">
          {criticalCount} critical {criticalCount === 1 ? "result" : "results"} in this panel.
        </p>
      )}

      {/*
        A results table has an irreducible minimum width — test, value, range,
        and interpretation cannot usefully collapse. Without this scroller the
        table is clipped by the nearest overflow-hidden ancestor and the
        Interpretation column disappears on a phone, silently removing the
        severity signal. Scroll, never truncate, when the data is clinical.

        tabIndex + role make the scroller reachable by keyboard, which is
        required once a region scrolls.
      */}
      <div
        role="region"
        aria-label={`${label}, scrollable`}
        tabIndex={0}
        className="w-full overflow-x-auto focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ox-focus-ring)]"
      >
        <table className="w-full min-w-[34rem] border-collapse text-[length:var(--ox-density-font)]">
          <caption className="sr-only">{label}</caption>
          <thead>
            <tr className="border-b border-[var(--ox-border)] bg-[var(--ox-bg-subtle)]">
              <Th className="text-left">Test</Th>
              <Th className="text-right">Result</Th>
              {!hideReferenceRange && <Th className="text-right">Reference</Th>}
              <Th className="text-left">Interpretation</Th>
            </tr>
          </thead>
          <tbody>
            {observations.map((observation, index) => (
              <React.Fragment key={observation.id ?? index}>
                <ObservationRow
                  observation={observation}
                  hideReferenceRange={hideReferenceRange}
                  onSelect={onSelect}
                />
                {/* Multi-part results (blood pressure, differentials) carry their
                  reading in components, not on the parent. Each renders as its
                  own indented row so systolic and diastolic are separately
                  readable and separately flaggable. */}
                {(observation.component ?? []).map((part, partIndex) => (
                  <ObservationComponentRow
                    key={partIndex}
                    component={part}
                    hideReferenceRange={hideReferenceRange}
                  />
                ))}
              </React.Fragment>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function Th({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <th
      scope="col"
      className={cn(
        "px-[var(--ox-density-pad-x)] py-2",
        "text-[length:var(--ox-text-xs)] font-semibold uppercase tracking-wide",
        "text-[var(--ox-text-subtle)]",
        className,
      )}
    >
      {children}
    </th>
  );
}

export function ObservationRow({
  observation,
  hideReferenceRange = false,
  onSelect,
}: {
  observation: Observation;
  hideReferenceRange?: boolean;
  onSelect?: (observation: Observation) => void;
}) {
  // Panel interpretation, not just the parent's: a critical systolic must
  // escalate the blood-pressure row even though the parent has no value.
  const interpretation = getPanelInterpretation(observation);
  const style = INTERPRETATION_CLASS[interpretation];
  const Icon = INTERPRETATION_ICON[interpretation];
  const critical = isCritical(interpretation);

  const name = codeableText(observation.code) ?? "Unnamed observation";
  const parts = observation.component ?? [];
  const value = formatObservationValue(observation);
  const range = formatReferenceRange(observation.referenceRange?.[0]);

  const interactive = Boolean(onSelect);

  return (
    <tr
      onClick={onSelect ? () => onSelect(observation) : undefined}
      onKeyDown={
        onSelect
          ? (event) => {
              if (event.key === "Enter" || event.key === " ") {
                event.preventDefault();
                onSelect(observation);
              }
            }
          : undefined
      }
      tabIndex={interactive ? 0 : undefined}
      role={interactive ? "button" : undefined}
      data-interpretation={interpretation}
      className={cn(
        "border-b border-[var(--ox-border)] last:border-b-0",
        "min-h-[var(--ox-density-row-height)]",
        interactive &&
          "cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ox-focus-ring)] hover:bg-[var(--ox-bg-subtle)]",
        // A critical result gets a left rule as well as its badge: a second,
        // non-color channel that survives grayscale and forced-colors.
        critical &&
          "bg-[var(--ox-status-critical-bg)] shadow-[inset_3px_0_0_0_var(--ox-status-critical)]",
      )}
    >
      <td className="px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)]">
        <div className="font-medium text-[var(--ox-text)]">{name}</div>
        <div className="mt-0.5 flex flex-wrap items-center gap-1.5">
          {isProvisional(observation) && (
            <StatusChip icon={<FileWarning aria-hidden="true" className="size-3" />}>
              Preliminary
            </StatusChip>
          )}
          {isCorrected(observation) && (
            <StatusChip icon={<PencilLine aria-hidden="true" className="size-3" />}>
              {observation.status === "amended" ? "Amended" : "Corrected"}
            </StatusChip>
          )}
        </div>
      </td>

      <td className="whitespace-nowrap px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)] text-right">
        {value ? (
          <span
            className={cn(
              "font-[family-name:var(--ox-font-numeric)] tabular-nums",
              critical ? "font-bold" : "font-medium",
              style.value,
            )}
          >
            {value}
          </span>
        ) : parts.length ? (
          // The reading is in the rows below. Saying "No value" here would be
          // wrong, and blank would look like a failure.
          <span className="text-[length:var(--ox-text-sm)] text-[var(--ox-text-subtle)]">
            {parts.length} parts
          </span>
        ) : (
          // Absence is a state, and which absence it is changes what the reader
          // should do. "Hidden — restricted" and "Not asked" are not the same
          // fact and must not share a rendering.
          <AbsentValue field={name} reason={observation.dataAbsentReason} />
        )}
      </td>

      {!hideReferenceRange && (
        <td className="whitespace-nowrap px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)] text-right">
          {range ? (
            <span className="font-[family-name:var(--ox-font-numeric)] tabular-nums text-[length:var(--ox-text-sm)] text-[var(--ox-text-muted)]">
              {range}
            </span>
          ) : (
            <Minus aria-hidden="true" className="ml-auto size-3.5 text-[var(--ox-text-subtle)]" />
          )}
        </td>
      )}

      <td className="px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)]">
        <span
          className={cn(
            "inline-flex items-center gap-1.5 rounded-[var(--ox-radius-full)] border px-2 py-0.5",
            "text-[length:var(--ox-text-xs)] font-semibold whitespace-nowrap",
            style.badge,
          )}
        >
          <Icon aria-hidden="true" className="size-3.5" />
          {INTERPRETATION_LABEL[interpretation]}
        </span>
      </td>
    </tr>
  );
}

/**
 * One part of a multi-component observation — a systolic reading, a
 * differential fraction. Indented under its parent and independently flagged,
 * because "blood pressure is abnormal" is not actionable but "systolic is
 * critical high" is.
 */
export function ObservationComponentRow({
  component,
  hideReferenceRange = false,
}: {
  component: ObservationComponent;
  hideReferenceRange?: boolean;
}) {
  const interpretation = getComponentInterpretation(component);
  const style = INTERPRETATION_CLASS[interpretation];
  const Icon = INTERPRETATION_ICON[interpretation];
  const critical = isCritical(interpretation);

  const name = codeableText(component.code) ?? "Component";
  const value = formatComponentValue(component);
  const range = formatReferenceRange(component.referenceRange?.[0]);

  return (
    <tr
      data-interpretation={interpretation}
      className={cn(
        "border-b border-[var(--ox-border)] last:border-b-0",
        critical &&
          "bg-[var(--ox-status-critical-bg)] shadow-[inset_3px_0_0_0_var(--ox-status-critical)]",
      )}
    >
      <td className="py-[var(--ox-density-pad-y)] pl-[calc(var(--ox-density-pad-x)*2)] pr-[var(--ox-density-pad-x)]">
        <span className="text-[var(--ox-text-muted)]">{name}</span>
      </td>

      <td className="whitespace-nowrap px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)] text-right">
        {value ? (
          <span
            className={cn(
              "font-[family-name:var(--ox-font-numeric)] tabular-nums",
              critical ? "font-bold" : "font-medium",
              style.value,
            )}
          >
            {value}
          </span>
        ) : (
          <AbsentValue field={name} reason={component.dataAbsentReason} />
        )}
      </td>

      {!hideReferenceRange && (
        <td className="whitespace-nowrap px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)] text-right">
          {range ? (
            <span className="font-[family-name:var(--ox-font-numeric)] tabular-nums text-[length:var(--ox-text-sm)] text-[var(--ox-text-muted)]">
              {range}
            </span>
          ) : (
            <Minus aria-hidden="true" className="ml-auto size-3.5 text-[var(--ox-text-subtle)]" />
          )}
        </td>
      )}

      <td className="px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)]">
        <span
          className={cn(
            "inline-flex items-center gap-1.5 rounded-[var(--ox-radius-full)] border px-2 py-0.5",
            "text-[length:var(--ox-text-xs)] font-semibold whitespace-nowrap",
            style.badge,
          )}
        >
          <Icon aria-hidden="true" className="size-3.5" />
          {INTERPRETATION_LABEL[interpretation]}
        </span>
      </td>
    </tr>
  );
}

function StatusChip({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
  return (
    <span className="inline-flex items-center gap-1 text-[length:var(--ox-text-xs)] font-medium text-[var(--ox-flag-provisional)]">
      {icon}
      {children}
    </span>
  );
}

export function ObservationPanelSkeleton({
  rows = 4,
  className,
  ...props
}: { rows?: number } & React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      role="status"
      aria-busy="true"
      aria-label="Loading results"
      className={cn(
        "overflow-hidden rounded-[var(--ox-radius-lg)] border border-[var(--ox-border)] bg-[var(--ox-surface)]",
        className,
      )}
      {...props}
    >
      <div className="border-b border-[var(--ox-border)] bg-[var(--ox-bg-subtle)] px-[var(--ox-density-pad-x)] py-2.5">
        <div className="h-3 w-24 animate-pulse rounded bg-[var(--ox-bg-muted)]" />
      </div>
      {Array.from({ length: rows }).map((_, index) => (
        <div
          key={index}
          className="flex items-center justify-between gap-4 border-b border-[var(--ox-border)] px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)] last:border-b-0"
        >
          <div className="h-4 w-32 animate-pulse rounded bg-[var(--ox-bg-muted)]" />
          <div className="h-4 w-16 animate-pulse rounded bg-[var(--ox-bg-muted)]" />
          <div className="h-4 w-20 animate-pulse rounded bg-[var(--ox-bg-muted)]" />
          <div className="h-5 w-24 animate-pulse rounded-full bg-[var(--ox-bg-muted)]" />
        </div>
      ))}
      <span className="sr-only">Loading results</span>
    </div>
  );
}