Skip to content
All components

Clinical Value

Stable

QuantityPrimitive · Clinical

The bug this prevents is mundane and everywhere: a value and its unit as separate nodes, which drift apart under truncation, wrapping, or translation. Here they are one element. Precision is never changed — a lab that reported 5.10 meant three significant figures. Comparators survive, because a result of <0.01 is not 0.01. Absence routes to AbsentValue; there is no path that renders an empty string.

pnpm dlx shadcn@latest add @oxygenui/clinical-value

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/clinical-value.json directly.

Preview

Every state, switchable.

Live · synthetic data
Potassium
6.8 millimoles per litre
Sodium
138 millimoles per litre
Troponin
less than 0.01 nanograms per millilitre
HbA1c
5.7 percent

Reported precision is never changed, and a comparator survives — <0.01 is not 0.01.

Numeric with unitComparator (<0.01)Non-numeric resultAbsent with a reasonAbsent with no reasonUnit missing

Usage

Props are the FHIR resource.

import { ClinicalValue } from "@/components/oxygen/clinical-value";

<ClinicalValue quantity={{ value: 6.8, unit: "mmol/L" }} tone="critical" bold />
<ClinicalValue quantity={{ value: 0.01, comparator: "<", unit: "ng/mL" }} />
<ClinicalValue field="Magnesium" absentReason={observation.dataAbsentReason} />

Dependencies

  • @oxygenui-design/fhir@^0.1.0
  • lucide-react
  • clsx
  • tailwind-merge
Clinical Value props
PropTypeDefault
absentReason

Why the value is absent, when it is. Passed straight to AbsentValue. Absence is rendered as a statement, never as a blank or a dash.

CodeableConcept | undefined
bold

Emphasise the number. Used for critical results.

boolean | undefinedfalse
field

Field name, used for the accessible name of an absent value.

string | undefined
hideUnit

Hide the unit. Only when a column header already carries it.

boolean | undefinedfalse
quantity

The measured quantity.

Quantity | undefined
size

'sm' | 'md' | 'lg' | 'hero' | undefined"md"
text

Non-numeric result — a titre, an organism, "Nonreactive".

string | undefined
tone

ValueTone | undefined"default"

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • Any place a measured quantity is displayed, in tables and in patient apps.
  • With tone set from a resolved interpretation, never from a raw colour.
  • With field set so an absent value announces which field it belongs to.

Guardrails

Don't

03
  • Re-rounding a value before passing it. Precision came from the source.
  • Splitting the unit into a sibling element — that is the failure this exists to prevent.
  • Using tone to convey severity on its own; pair it with a badge.

Quality

What was tested, and what is still missing.

One accessible name
The comparator, number, and expanded unit are announced as a single phrase, so a screen reader never reads a bare number.
Unit expansion
Common units are spoken in full — mg/dL and mmol/L differ by a factor that matters. Unknown units are announced as written rather than guessed at.
Tabular figures
Decimal points align down a column, which is what makes a dense results table scannable.

Open gaps

Known limitations

03
  • Unit conversion is not performed. Convert upstream and pass the value you want shown.
  • The spoken-unit table covers common units only; an unlisted unit is read as written.
  • Tone is presentational \u2014 it does not derive severity from the value.

Source

Exactly what lands in your repository.

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

Show source173 lines
"use client";

/**
 * ClinicalValue — one measured quantity, its unit, and its interpretation as a
 * single atomic element.
 *
 * The bug this exists to prevent is mundane and everywhere: a value and its
 * unit laid out as separate nodes, which then drift apart under truncation,
 * wrapping, or translation. "5.1" next to "mmol/L" in a narrow column can wrap
 * so the unit lands beside the row below it. Here they are one element and
 * cannot separate.
 *
 * Three rules the API enforces:
 *
 *   1. Precision is never changed. A lab that reported 5.10 meant three
 *      significant figures; re-rounding it to 5.1 discards information the
 *      source deliberately included.
 *   2. Comparators survive. A result of <0.01 is not 0.01, and dropping the
 *      comparator turns "undetectable" into a number.
 *   3. Absence routes to AbsentValue. There is no code path here that renders
 *      an empty string.
 */

import * as React from "react";
import { quantityParts, type CodeableConcept, type Quantity } from "@oxygenui-design/fhir";
import { AbsentValue } from "@/components/oxygen/absent-value";
import { cn } from "@/lib/utils";

/** Emphasis, not severity. Severity belongs to StatusBadge and the row. */
type ValueTone = "default" | "critical" | "high" | "low" | "normal" | "muted";

/**
 * Class names written out in full. Tailwind resolves classes by scanning source
 * text, so a template literal built from `tone` produces no CSS and the value
 * renders unstyled — which silently deletes the severity signal.
 */
const TONE_CLASS: Record<ValueTone, string> = {
  default: "text-[var(--ox-text)]",
  critical: "text-[var(--ox-status-critical)]",
  high: "text-[var(--ox-status-high)]",
  low: "text-[var(--ox-status-low)]",
  normal: "text-[var(--ox-status-normal)]",
  muted: "text-[var(--ox-text-muted)]",
};

const SIZE_CLASS = {
  sm: "text-[length:var(--ox-text-sm)]",
  md: "text-[length:var(--ox-density-font)]",
  lg: "text-[length:var(--ox-text-xl)]",
  hero: "text-[length:var(--ox-text-2xl)]",
} as const;

export interface ClinicalValueProps extends Omit<
  React.HTMLAttributes<HTMLSpanElement>,
  "children"
> {
  /** The measured quantity. */
  quantity?: Quantity;
  /** Non-numeric result — a titre, an organism, "Nonreactive". */
  text?: string;
  /**
   * Why the value is absent, when it is. Passed straight to AbsentValue.
   * Absence is rendered as a statement, never as a blank or a dash.
   */
  absentReason?: CodeableConcept;
  /** Field name, used for the accessible name of an absent value. */
  field?: string;
  tone?: ValueTone;
  size?: keyof typeof SIZE_CLASS;
  /** Emphasise the number. Used for critical results. */
  bold?: boolean;
  /** Hide the unit. Only when a column header already carries it. */
  hideUnit?: boolean;
}

/**
 * Expansions for the abbreviations most likely to be misread aloud.
 *
 * mg/dL and mmol/L differ by a factor that matters, and a screen reader saying
 * "em em oh ell slash ell" is not a unit. Only unambiguous expansions are
 * listed; an unknown unit is announced as written rather than guessed at.
 */
const UNIT_SPOKEN: Record<string, string> = {
  "mg/dL": "milligrams per decilitre",
  "g/dL": "grams per decilitre",
  "mmol/L": "millimoles per litre",
  "mIU/L": "milli-international units per litre",
  "µg/L": "micrograms per litre",
  "ng/mL": "nanograms per millilitre",
  mmHg: "millimetres of mercury",
  "/min": "per minute",
  "%": "percent",
  "°C": "degrees Celsius",
  "°F": "degrees Fahrenheit",
  kg: "kilograms",
  cm: "centimetres",
};

const COMPARATOR_SPOKEN: Record<string, string> = {
  "<": "less than",
  "<=": "less than or equal to",
  ">": "greater than",
  ">=": "greater than or equal to",
};

export function ClinicalValue({
  quantity,
  text,
  absentReason,
  field,
  tone = "default",
  size = "md",
  bold = false,
  hideUnit = false,
  className,
  ...props
}: ClinicalValueProps) {
  const parts = quantityParts(quantity);

  if (!parts && !text) {
    return <AbsentValue field={field} reason={absentReason} />;
  }

  if (!parts) {
    return (
      <span
        className={cn(SIZE_CLASS[size], TONE_CLASS[tone], bold && "font-semibold", className)}
        {...props}
      >
        {text}
      </span>
    );
  }

  const { comparator, value, unit } = parts;

  // The spoken form is assembled so a screen reader never reads a bare number.
  // "6.8" alone is meaningless; "6.8 millimoles per litre" is a result.
  const spoken = [
    comparator ? COMPARATOR_SPOKEN[comparator] : undefined,
    value,
    unit ? (UNIT_SPOKEN[unit] ?? unit) : undefined,
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <span
      className={cn(
        "inline-flex items-baseline gap-1 whitespace-nowrap",
        "font-[family-name:var(--ox-font-numeric)] tabular-nums",
        SIZE_CLASS[size],
        TONE_CLASS[tone],
        bold ? "font-bold" : "font-medium",
        className,
      )}
      {...props}
    >
      {/* One accessible name for the whole quantity, not three fragments. */}
      <span className="sr-only">{spoken}</span>
      <span aria-hidden="true">
        {comparator}
        {value}
      </span>
      {unit && !hideUnit && (
        <span aria-hidden="true" className="font-normal text-[var(--ox-text-muted)]">
          {unit}
        </span>
      )}
    </span>
  );
}