Skip to content
All components

Concept Chip

Stable

CodeableConceptPrimitive · Clinical

Clinicians read display text; integrations and audits need the code. Hiding the coding makes data problems undiagnosable, showing it inline makes every list unreadable, so it lives behind a disclosure. The useful behaviour is the honesty: text with no coding is marked as such, an unrecognised system shows its raw URI rather than being dressed up as standard, and a concept outside an expected value set is flagged so bad mappings become visible instead of accumulating.

pnpm dlx shadcn@latest add @oxygenui/concept-chip

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/concept-chip.json directly.

Preview

Every state, switchable.

Live · synthetic data

Select a chip to see the coding. Clinicians read text; audits and integrations need the code.

Coded conceptText only, no codingMultiple codingsUnrecognised systemOutside expected value setNo concept recorded

Usage

Props are the FHIR resource.

import { ConceptChip } from "@/components/oxygen/concept-chip";

<ConceptChip concept={condition.code} />
<ConceptChip concept={observation.code} showSystem />
<ConceptChip concept={concept} expectedCodes={["http://loinc.org|2823-3"]} />

Dependencies

  • @oxygenui-design/fhir@^0.1.0
  • lucide-react
  • clsx
  • tailwind-merge
Concept Chip props
PropTypeDefault
concept

CodeableConcept | undefined
expectedCodes

Codes the concept is expected to carry, as "system|code" or bare "code". A concept outside this set is flagged rather than silently accepted.

string[] | undefined
field

Field name for the accessible name when the chip stands alone.

string | undefined
readOnly

Render as plain text with no disclosure. For dense grids.

boolean | undefinedfalse
showSystem

Show the terminology name inline, e.g. "SNOMED CT · Hypertension".

boolean | undefinedfalse
size

'sm' | 'md' | undefined"sm"

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • Problem lists, order details, and anywhere a coded concept is shown to a clinician.
  • With expectedCodes on surfaces where mapping quality matters.
  • With readOnly in virtualised grids, where a popover per row is unusable.

Guardrails

Don't

03
  • Rendering the system URI as though it were a friendly name. Unrecognised means unrecognised.
  • Using it for concepts you never intend a user to inspect \u2014 readOnly text is cheaper.
  • Assuming a coded concept is valid because it renders. The chip shows what is there.

Quality

What was tested, and what is still missing.

Button, not hover
The disclosure is a real button with an expanded state, dismissible with Escape. Nothing is hover-only.
Code out of the name
The accessible name carries the concept text and any warning; system and code live inside the disclosure rather than crowding it.
Warnings in text
Text-only and out-of-value-set are labelled in words, never by colour alone.

Open gaps

Known limitations

03
  • No terminology server lookup \u2014 it displays what the payload carries.
  • Post-coordinated SNOMED expressions render as their raw code.
  • The recognised-system list is finite; extend it as your integrations grow.

Source

Exactly what lands in your repository.

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

Show source187 lines
"use client";

/**
 * ConceptChip — a coded clinical concept with its coding reachable on demand.
 *
 * Clinicians read display text. Integrations, audits, and anyone debugging a
 * mapping need the code. Hiding the coding entirely makes data problems
 * undiagnosable; showing it inline makes every list unreadable. So it lives
 * one interaction away.
 *
 * The useful behaviour is the honesty about what is missing:
 *
 *   - Text with no coding at all is extremely common and is marked as such,
 *     because an uncoded concept cannot drive a rule or a report.
 *   - A code from a system we do not recognise is shown with its raw system
 *     URI rather than silently rendered as though it were standard.
 *   - When an expected value set is supplied, a concept outside it is flagged.
 *     That flag is how bad mappings become visible instead of accumulating.
 */

import * as React from "react";
import { CircleAlert, Tag } from "lucide-react";
import { codeableText, terminologyName, type CodeableConcept } from "@oxygenui-design/fhir";
import { cn } from "@/lib/utils";

export interface ConceptChipProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  concept?: CodeableConcept;
  /**
   * Codes the concept is expected to carry, as "system|code" or bare "code".
   * A concept outside this set is flagged rather than silently accepted.
   */
  expectedCodes?: string[];
  /** Show the terminology name inline, e.g. "SNOMED CT · Hypertension". */
  showSystem?: boolean;
  /** Render as plain text with no disclosure. For dense grids. */
  readOnly?: boolean;
  size?: "sm" | "md";
  /** Field name for the accessible name when the chip stands alone. */
  field?: string;
}

export function ConceptChip({
  concept,
  expectedCodes,
  showSystem = false,
  readOnly = false,
  size = "sm",
  field,
  className,
  ...props
}: ConceptChipProps) {
  const [open, setOpen] = React.useState(false);
  const text = codeableText(concept);
  const codings = concept?.coding ?? [];

  // Absence is a state here too — an uncoded, untexted concept is not blank.
  if (!text) {
    return (
      <span
        className={cn("text-[length:var(--ox-text-sm)] text-[var(--ox-text-subtle)]", className)}
      >
        No concept recorded
      </span>
    );
  }

  const primary = codings.find((c) => c.userSelected) ?? codings[0];
  const systemName = primary?.system ? terminologyName(primary.system) : undefined;
  const uncoded = codings.length === 0;

  const outsideValueSet =
    expectedCodes?.length && codings.length
      ? !codings.some((c) =>
          expectedCodes.some((expected) =>
            expected.includes("|") ? expected === `${c.system}|${c.code}` : expected === c.code,
          ),
        )
      : false;

  const spoken = [
    field,
    text,
    uncoded ? "no code recorded" : undefined,
    outsideValueSet ? "outside the expected value set" : undefined,
  ]
    .filter(Boolean)
    .join(", ");

  const body = (
    <>
      <span className="sr-only">{spoken}</span>
      <span aria-hidden="true" className="truncate">
        {showSystem && systemName && (
          <span className="mr-1 font-[family-name:var(--ox-font-mono)] text-[length:var(--ox-text-2xs)] text-[var(--ox-text-subtle)]">
            {systemName}
          </span>
        )}
        {text}
      </span>
      {uncoded && (
        <span
          aria-hidden="true"
          title="Text only — no code recorded"
          className="font-[family-name:var(--ox-font-mono)] text-[length:var(--ox-text-2xs)] text-[var(--ox-text-subtle)]"
        >
          text
        </span>
      )}
      {outsideValueSet && (
        <CircleAlert aria-hidden="true" className="size-3.5 text-[var(--ox-status-high)]" />
      )}
    </>
  );

  const shell = cn(
    "inline-flex max-w-full items-center gap-1.5 rounded-[var(--ox-radius-sm)] border px-1.5 py-0.5",
    size === "sm" ? "text-[length:var(--ox-text-sm)]" : "text-[length:var(--ox-density-font)]",
    outsideValueSet
      ? "border-[var(--ox-status-high-border)] bg-[var(--ox-status-high-bg)]"
      : "border-[var(--ox-border)] bg-[var(--ox-bg-subtle)]",
    className,
  );

  if (readOnly || uncoded) {
    return (
      <span className={shell} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
        {body}
      </span>
    );
  }

  return (
    <span className="relative inline-flex max-w-full">
      <button
        type="button"
        aria-expanded={open}
        onClick={() => setOpen((v) => !v)}
        onKeyDown={(event) => {
          if (event.key === "Escape" && open) {
            event.stopPropagation();
            setOpen(false);
          }
        }}
        className={cn(shell, "cursor-pointer hover:border-[var(--ox-border-strong)]")}
        {...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}
      >
        {body}
        <Tag aria-hidden="true" className="size-3 shrink-0 text-[var(--ox-text-subtle)]" />
      </button>

      {open && (
        <span
          role="group"
          aria-label={`Codings for ${text}`}
          className="absolute left-0 top-full z-20 mt-1 min-w-64 rounded-[var(--ox-radius)] border border-[var(--ox-border)] bg-[var(--ox-surface-overlay)] p-2 shadow-[var(--ox-shadow-md)]"
        >
          {codings.map((coding, index) => (
            <span
              key={`${coding.system ?? "?"}-${coding.code ?? index}`}
              className="flex flex-col gap-0.5 border-b border-[var(--ox-border)] py-1.5 last:border-b-0"
            >
              <span className="flex items-center gap-2 font-[family-name:var(--ox-font-mono)] text-[length:var(--ox-text-xs)]">
                <span className="font-semibold text-[var(--ox-text)]">{coding.code ?? "—"}</span>
                {coding.userSelected && (
                  <span className="text-[length:var(--ox-text-2xs)] text-[var(--ox-accent)]">
                    user selected
                  </span>
                )}
              </span>
              <span className="text-[length:var(--ox-text-xs)] text-[var(--ox-text-muted)]">
                {coding.display ?? "No display text"}
              </span>
              {/* An unrecognised system shows its URI. Echoing the URI as a
                  friendly name would hide exactly the mapping problem worth
                  seeing. */}
              <span className="break-all font-[family-name:var(--ox-font-mono)] text-[length:var(--ox-text-2xs)] text-[var(--ox-text-subtle)]">
                {terminologyName(coding.system) ?? coding.system ?? "No system"}
                {coding.version ? ` · v${coding.version}` : ""}
              </span>
            </span>
          ))}
        </span>
      )}
    </span>
  );
}