Observation Panel
StableObservation[]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-panelFirst 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.
1 critical result in this panel.
| Test | Result | Reference | Interpretation |
|---|---|---|---|
Potassium | 6.8 mmol/L | 3.5 – 5.1 mmol/L | Critical high |
Blood pressure | 2 parts | High | |
| Systolic | 168 mmHg | 90 – 130 mmHg | High |
| Diastolic | 82 mmHg | 60 – 85 mmHg | Normal |
Hemoglobin | 10.2 g/dL | 12 – 15.5 g/dL | Low |
Glucose Corrected | 156 mg/dL | 70 – 100 mg/dL | High |
Heart rate | 72 beats/min | 60 – 100 beats/min | Normal |
TSH Preliminary | 5.9 mIU/L | 0.4 – 4 mIU/L | High |
Ferritin | 43 ng/mL | Not interpreted | |
White blood cell count | White blood cell count, Not known— Specimen hemolyzed — recollect | Not interpreted |
Severity reaches the reader three ways: badge, left rule, and a live-region announcement.
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
| Prop | Type | Default |
|---|---|---|
| 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 | undefined | false |
| label Accessible name for the results table. | string | undefined | "Observations" |
| loading Renders the skeleton state. | boolean | undefined | false |
| loadingRows Number of skeleton rows while loading. | number | undefined | 4 |
| 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
- 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
- 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
- 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 sourceHide 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>
);
}