Skip to content
All components

Condition List

Stable

ConditionClinical data · Clinical

Renders a problem list from Condition resources. A problem list is not a log — its value comes from the reader telling at a glance which problems are current, so resolved and inactive entries are grouped separately rather than merely sorted below. Onset renders exactly as recorded: FHIR permits onsetString (“in childhood”) alongside onsetDateTime, and coercing a vague onset into a false precise date is a common and quietly damaging bug.

pnpm dlx shadcn@latest add @oxygenui/condition-list

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/condition-list.json directly.

Preview

Every state, switchable.

Live · synthetic data
Essential hypertension
Onset 2021-04-09
Moderate
Asthma
Onset in childhood
Mild
Iron deficiency anaemia
Onset 2026-07-28
Provisional
1 resolved or inactive problem
Community-acquired pneumonia
Onset 2025-11-02Resolved 2025-11-26
SevereResolved

Active problems first; resolved collapsed into their own group.

ActiveRecurrenceRemissionResolvedInactiveProvisionalDifferentialVague onsetOnset not recorded

Usage

Props are the FHIR resource.

import { ConditionList } from "@/components/oxygen/condition-list";

<ConditionList
  conditions={problems}
  separateInactive
/>

Dependencies

  • @oxygenui-design/fhir@^0.1.0
  • lucide-react
  • clsx
  • tailwind-merge
Condition List props
PropTypeDefault
conditions

Condition[] | undefined
emptyMessage

string | undefined"No problems recorded."
label

string | undefined"Problem list"
loading

boolean | undefinedfalse
separateInactive

Render resolved and inactive problems in a separate, collapsed group.

boolean | undefinedtrue

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • On chart summaries, visit preparation, and handoff screens.
  • With separateInactive on any screen where current problems drive the next action.
  • Preserving your own clinical sort order — the component does not reorder within a group.

Guardrails

Don't

03
  • As an encounter diagnosis list. Encounter diagnoses are scoped to a visit, not to the patient.
  • Hiding provisional diagnoses. Carrying one forward as settled fact is the harm; hiding it is not the fix.
  • separateInactive off on dense clinical screens, where the split is what makes the list scannable.

Quality

What was tested, and what is still missing.

Native disclosure
The inactive group uses details/summary, so it is keyboard operable and announced without custom ARIA.
Provisional is labelled
Provisional and differential diagnoses carry an explicit badge rather than a subtle style difference.
Onset honesty
A vague onset renders as its recorded text; a missing one renders as explicitly not recorded.

Open gaps

Known limitations

03
  • Body site, stage, and evidence are not rendered.
  • No grouping by category (problem list item vs encounter diagnosis).
  • Severity is matched on display text; coded severity value sets are not yet mapped.

Source

Exactly what lands in your repository.

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

Show source223 lines
"use client";

/**
 * ConditionList — the problem list, from FHIR Condition resources.
 *
 * A problem list is not a log. Its value comes from the reader being able to
 * tell, at a glance, which problems are current — so resolved and inactive
 * entries are visually separated rather than merely sorted below, and the
 * distinction between "provisional" and "confirmed" is never dropped.
 *
 * Onset is rendered as recorded. FHIR permits `onsetString` ("in childhood")
 * alongside `onsetDateTime`, and coercing a vague onset into a false precise
 * date is a common and quietly damaging bug.
 */

import * as React from "react";
import { Activity, CircleDot, CircleHelp } from "lucide-react";
import {
  clinicalStatusCode,
  codeableText,
  verificationStatusCode,
  type Condition,
} from "@oxygenui-design/fhir";
import { StatusBadge, type StatusTone } from "@/components/oxygen/status-badge";
import { cn } from "@/lib/utils";

function severityTone(condition: Condition): StatusTone {
  const severity = codeableText(condition.severity)?.toLowerCase();
  if (severity?.includes("severe")) return "critical";
  if (severity?.includes("moderate")) return "high";
  if (severity?.includes("mild")) return "normal";
  return "unknown";
}

function isActive(condition: Condition): boolean {
  const status = clinicalStatusCode(condition);
  return status === "active" || status === "recurrence" || status === "relapse";
}

/** Onset exactly as recorded — never coerced into a precision it doesn't have. */
function formatOnset(condition: Condition): string | undefined {
  if (condition.onsetDateTime) return condition.onsetDateTime.slice(0, 10);
  if (condition.onsetString) return condition.onsetString;
  return undefined;
}

export interface ConditionListProps extends React.HTMLAttributes<HTMLDivElement> {
  conditions: Condition[] | undefined;
  /** Render resolved and inactive problems in a separate, collapsed group. */
  separateInactive?: boolean;
  loading?: boolean;
  label?: string;
  emptyMessage?: string;
}

export function ConditionList({
  conditions,
  separateInactive = true,
  loading = false,
  label = "Problem list",
  emptyMessage = "No problems recorded.",
  className,
  ...props
}: ConditionListProps) {
  if (loading) {
    return (
      <div
        role="status"
        aria-busy="true"
        aria-label="Loading problem list"
        className={cn(
          "space-y-2 rounded-[var(--ox-radius-lg)] border border-[var(--ox-border)] bg-[var(--ox-surface)] p-[var(--ox-density-pad-x)]",
          className,
        )}
        {...props}
      >
        {[0, 1, 2].map((i) => (
          <div key={i} className="h-9 animate-pulse rounded bg-[var(--ox-bg-muted)]" />
        ))}
        <span className="sr-only">Loading problem list</span>
      </div>
    );
  }

  if (!conditions?.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 active = separateInactive ? conditions.filter(isActive) : conditions;
  const inactive = separateInactive ? conditions.filter((c) => !isActive(c)) : [];

  return (
    <div
      role="region"
      aria-label={label}
      className={cn(
        "overflow-hidden rounded-[var(--ox-radius-lg)] border border-[var(--ox-border)] bg-[var(--ox-surface)]",
        className,
      )}
      {...props}
    >
      <div className="divide-y divide-[var(--ox-border)]">
        {active.map((condition, index) => (
          <ConditionRow key={condition.id ?? index} condition={condition} />
        ))}
      </div>

      {inactive.length > 0 && (
        <details className="border-t border-[var(--ox-border)] bg-[var(--ox-bg-subtle)]">
          <summary className="cursor-pointer px-[var(--ox-density-pad-x)] py-2 text-[length:var(--ox-text-sm)] text-[var(--ox-text-muted)] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ox-focus-ring)]">
            {inactive.length} resolved or inactive {inactive.length === 1 ? "problem" : "problems"}
          </summary>
          <div className="divide-y divide-[var(--ox-border)] border-t border-[var(--ox-border)]">
            {inactive.map((condition, index) => (
              <ConditionRow key={condition.id ?? index} condition={condition} />
            ))}
          </div>
        </details>
      )}
    </div>
  );
}

function ConditionRow({ condition }: { condition: Condition }) {
  const name = codeableText(condition.code);
  const severity = codeableText(condition.severity);
  const tone = severityTone(condition);
  const clinical = clinicalStatusCode(condition);
  const verification = verificationStatusCode(condition);
  const onset = formatOnset(condition);
  const active = isActive(condition);
  const provisional = verification === "provisional" || verification === "differential";

  return (
    <div
      data-clinical-status={clinical ?? "unknown"}
      className={cn(
        "flex flex-wrap items-start justify-between gap-x-4 gap-y-1.5",
        "px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)]",
        "text-[length:var(--ox-density-font)]",
      )}
    >
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          {active ? (
            <CircleDot aria-hidden="true" className="size-3.5 shrink-0 text-[var(--ox-accent)]" />
          ) : (
            <Activity
              aria-hidden="true"
              className="size-3.5 shrink-0 text-[var(--ox-text-subtle)]"
            />
          )}
          <span
            className={cn(
              "font-medium text-[var(--ox-text)]",
              !active && "text-[var(--ox-text-muted)]",
            )}
          >
            {name ?? (
              <span className="font-normal italic text-[var(--ox-text-muted)]">
                Problem not recorded
              </span>
            )}
          </span>
        </div>

        <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 pl-5.5 text-[length:var(--ox-text-xs)] text-[var(--ox-text-subtle)]">
          {onset ? (
            <span>
              Onset{" "}
              <span className="font-[family-name:var(--ox-font-numeric)] tabular-nums">
                {onset}
              </span>
            </span>
          ) : (
            <span className="italic">Onset not recorded</span>
          )}
          {condition.abatementDateTime && (
            <span>
              Resolved{" "}
              <span className="font-[family-name:var(--ox-font-numeric)] tabular-nums">
                {condition.abatementDateTime.slice(0, 10)}
              </span>
            </span>
          )}
        </div>
      </div>

      <div className="flex shrink-0 flex-wrap items-center gap-1.5">
        {/* A provisional diagnosis carried forward as settled fact is one of
            the most consequential UI errors in a chart. */}
        {provisional && (
          <StatusBadge tone="unknown" icon={CircleHelp}>
            {verification === "differential" ? "Differential" : "Provisional"}
          </StatusBadge>
        )}
        {severity && <StatusBadge tone={tone}>{severity}</StatusBadge>}
        {!active && (
          <StatusBadge tone="neutral">
            {clinical === "resolved"
              ? "Resolved"
              : clinical === "remission"
                ? "Remission"
                : "Inactive"}
          </StatusBadge>
        )}
      </div>
    </div>
  );
}