Skip to content
All components

Identity Token

Stable

PatientPrimitive · Patient identity

Wrong-patient error begins with an identity affordance that looked close enough. A photo renders only when consent is explicitly asserted — the component will not infer consent from a photo being present in the resource. A secondary identifier shows by default, because a name alone does not distinguish two people called J. Patel. Initials and colour derive from the characters of the name only; an avatar is not a classifier.

pnpm dlx shadcn@latest add @oxygenui/identity-token

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/identity-token.json directly.

Preview

Every state, switchable.

Live · synthetic data
Amara Okonkwo, age 42 y, MRN 093-441-208
Devin Marchetti, age 28 y, MRN 077-310-994, restricted record
Harold J Whitfield, age 85 y, MRN 058-901-772, deceased
Name not recorded, MRN 110-288-341

Restricted and deceased are words, not icons a reader has to know. A missing name is not a blank.

Complete identityName not recordedRestricted recordDeceasedPhoto without consentName-alike collisionMasked identifiers

Usage

Props are the FHIR resource.

import { IdentityToken } from "@/components/oxygen/identity-token";

<IdentityToken patient={patient} identifierSystem={MRN_SYSTEM} />
<IdentityToken patient={patient} maskIdentifiers avatarOnly />
<IdentityToken patient={patient} nameAlike />

Dependencies

  • @oxygenui-design/fhir@^0.1.0
  • lucide-react
  • clsx
  • tailwind-merge
Identity Token props
PropTypeDefault
asOf

Date | undefinednew Date()
avatarOnly

Avatar only. The name still reaches assistive technology.

boolean | undefinedfalse
identifierLabel

string | undefined"MRN"
identifierSystem

Identifier system to display, e.g. your MRN system.

string | undefined
maskIdentifiers

Mask all but the last four characters. For shared and public screens.

boolean | undefinedfalse
nameAlike

Another patient in the same view whose name is confusingly similar. Renders an explicit warning — name-alike collisions are a list-level fact that an item-level component cannot detect on its own.

boolean | undefinedfalse
patient

Patient | undefined
photoConsent

Show the patient's photo. Requires an explicit assertion that the patient consented to it being displayed — the component will not infer consent from the photo simply being present in the resource.

boolean | undefinedfalse
showDetail

Show age and secondary identifier under the name.

boolean | undefinedtrue
size

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

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • Worklists, care-team panels, search results, and anywhere a person is named.
  • With nameAlike set by the list when two entries are confusable.
  • With maskIdentifiers on shared workstations and public-facing screens.

Guardrails

Don't

03
  • Passing photoConsent as a constant true. It is an assertion about the patient, not a display preference.
  • Using the avatar colour to mean anything. It is decorative and stable, nothing more.
  • Omitting the identifier in a list where two patients could share a name.

Quality

What was tested, and what is still missing.

Full name never truncated
The visible name may truncate; the accessible name carries the whole identity, age, identifier, and flags as one phrase.
Flags as text
Restricted and deceased are words, not icons a reader has to know. Photos carry empty alt because the adjacent name is the label.
Name-alike announced
The collision warning is in the accessible name, not conveyed by styling alone.

Open gaps

Known limitations

03
  • Name-alike detection is list-level; the component renders the warning but cannot detect the collision itself.
  • Age precision uses birthDate only \u2014 sub-day precision for neonates needs a birth time the resource rarely carries.
  • Initial derivation is Latin-script-biased for multi-word names, though single tokens work in any script.

Source

Exactly what lands in your repository.

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

Show source218 lines
"use client";

/**
 * IdentityToken — a compact, privacy-aware representation of a person.
 *
 * Wrong-patient error begins with an identity affordance that looked close
 * enough, so this component is built around confirming rather than merely
 * labelling:
 *
 *   - A photo renders only when consent is explicitly stated. Absent the flag,
 *     initials. Consent is not assumed from the presence of a photo.
 *   - A secondary identifier is configurable and shown by default, because a
 *     name alone does not distinguish two people called J. Patel.
 *   - Deceased and restricted are permanent facts about the record and are
 *     rendered as text, not as an icon a reader has to know.
 *
 * Initials and colour are derived from the characters of the name only.
 * Nothing here infers anything demographic — an avatar is not a classifier.
 */

import * as React from "react";
import { Lock, ShieldAlert } from "lucide-react";
import {
  formatAge,
  isDeceased,
  isRestricted,
  maskIdentifier,
  nameInitials,
  resolvePatientName,
  getIdentifier,
  type Patient,
} from "@oxygenui-design/fhir";
import { cn } from "@/lib/utils";

const SIZE = {
  sm: { box: "size-7 text-[length:var(--ox-text-2xs)]", name: "text-[length:var(--ox-text-sm)]" },
  md: {
    box: "size-9 text-[length:var(--ox-text-xs)]",
    name: "text-[length:var(--ox-density-font)]",
  },
  lg: { box: "size-11 text-[length:var(--ox-text-sm)]", name: "text-[length:var(--ox-text-lg)]" },
} as const;

/**
 * Six decorative swatches. Written out in full because Tailwind cannot see a
 * class assembled from a variable, and a colourless avatar is a broken one.
 *
 * They come from the dedicated `--ox-swatch-*` group rather than from status
 * tokens. Borrowing a clinical colour for decoration puts the amber that means
 * "high" on an avatar because of a hash of someone's name, three rows above a
 * result where that same amber is a finding.
 *
 * Decorative only — never the sole carrier of identity, and never mapped to
 * anything about the person.
 */
const SWATCH = [
  "bg-[var(--ox-swatch-1-bg)] text-[var(--ox-swatch-1)] border-[var(--ox-swatch-1-border)]",
  "bg-[var(--ox-swatch-2-bg)] text-[var(--ox-swatch-2)] border-[var(--ox-swatch-2-border)]",
  "bg-[var(--ox-swatch-3-bg)] text-[var(--ox-swatch-3)] border-[var(--ox-swatch-3-border)]",
  "bg-[var(--ox-swatch-4-bg)] text-[var(--ox-swatch-4)] border-[var(--ox-swatch-4-border)]",
  "bg-[var(--ox-swatch-5-bg)] text-[var(--ox-swatch-5)] border-[var(--ox-swatch-5-border)]",
  "bg-[var(--ox-swatch-6-bg)] text-[var(--ox-swatch-6)] border-[var(--ox-swatch-6-border)]",
];

/** Used when the seed is empty. A swatch is decoration; there is always one. */
const SWATCH_FALLBACK =
  "bg-[var(--ox-swatch-6-bg)] text-[var(--ox-swatch-6)] border-[var(--ox-swatch-6-border)]";

/** Stable index from the name's characters. Same name, same swatch, always. */
function swatchFor(seed: string): string {
  let hash = 0;
  for (let i = 0; i < seed.length; i += 1) hash = (hash * 31 + seed.charCodeAt(i)) % 997;
  return SWATCH[hash % SWATCH.length] ?? SWATCH_FALLBACK;
}

export interface IdentityTokenProps extends React.HTMLAttributes<HTMLDivElement> {
  patient?: Patient;
  /** Identifier system to display, e.g. your MRN system. */
  identifierSystem?: string;
  identifierLabel?: string;
  /** Mask all but the last four characters. For shared and public screens. */
  maskIdentifiers?: boolean;
  /**
   * Show the patient's photo. Requires an explicit assertion that the patient
   * consented to it being displayed — the component will not infer consent
   * from the photo simply being present in the resource.
   */
  photoConsent?: boolean;
  size?: keyof typeof SIZE;
  /** Avatar only. The name still reaches assistive technology. */
  avatarOnly?: boolean;
  /** Show age and secondary identifier under the name. */
  showDetail?: boolean;
  /**
   * Another patient in the same view whose name is confusingly similar.
   * Renders an explicit warning — name-alike collisions are a list-level fact
   * that an item-level component cannot detect on its own.
   */
  nameAlike?: boolean;
  asOf?: Date;
}

export function IdentityToken({
  patient,
  identifierSystem,
  identifierLabel = "MRN",
  maskIdentifiers = false,
  photoConsent = false,
  size = "md",
  avatarOnly = false,
  showDetail = true,
  nameAlike = false,
  asOf = new Date(),
  className,
  ...props
}: IdentityTokenProps) {
  const name = resolvePatientName(patient);
  const restricted = isRestricted(patient);
  const deceased = isDeceased(patient);

  // An unidentified trauma patient is a real and recurring case. The record
  // exists, the person is real, and the component must not render blank.
  const displayName = name ?? "Name not recorded";
  const initials = nameInitials(name) ?? "?";

  const identifier = getIdentifier(patient, identifierSystem)?.value;
  const shownIdentifier = maskIdentifiers ? maskIdentifier(identifier) : identifier;
  const age = formatAge(patient?.birthDate, asOf);

  const photo = photoConsent ? patient?.photo?.find((p) => p.url)?.url : undefined;
  const sizes = SIZE[size];

  const spoken = [
    displayName,
    age ? `age ${age}` : undefined,
    shownIdentifier ? `${identifierLabel} ${shownIdentifier}` : undefined,
    restricted ? "restricted record" : undefined,
    deceased ? "deceased" : undefined,
    nameAlike ? "similar name in this list, confirm identity" : undefined,
  ]
    .filter(Boolean)
    .join(", ");

  const avatar = (
    <span
      aria-hidden="true"
      className={cn(
        "flex shrink-0 items-center justify-center overflow-hidden rounded-[var(--ox-radius)] border font-semibold",
        sizes.box,
        swatchFor(displayName),
        deceased && "grayscale",
      )}
    >
      {photo ? (
        // Decorative: the adjacent name is the label, so alt is empty.
        // eslint-disable-next-line @next/next/no-img-element
        <img src={photo} alt="" className="size-full object-cover" />
      ) : (
        initials
      )}
    </span>
  );

  if (avatarOnly) {
    return (
      <div className={cn("inline-flex", className)} {...props}>
        <span className="sr-only">{spoken}</span>
        {avatar}
      </div>
    );
  }

  return (
    <div className={cn("flex items-center gap-[var(--ox-density-gap)]", className)} {...props}>
      <span className="sr-only">{spoken}</span>
      {avatar}

      <div aria-hidden="true" className="min-w-0">
        <div className={cn("flex flex-wrap items-center gap-1.5 font-semibold", sizes.name)}>
          {/* Truncates from the end but keeps the family name reachable via
              the accessible name above, which is never truncated. */}
          <span className="truncate">{displayName}</span>

          {restricted && (
            <span className="inline-flex items-center gap-1 rounded-[var(--ox-radius-sm)] border border-[#ddd6fe] bg-[var(--ox-flag-restricted-bg)] px-1.5 py-0.5 text-[length:var(--ox-text-2xs)] font-semibold text-[var(--ox-flag-restricted)]">
              <Lock className="size-3" />
              Restricted
            </span>
          )}
          {deceased && (
            <span className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-border-strong)] bg-[var(--ox-bg-muted)] px-1.5 py-0.5 text-[length:var(--ox-text-2xs)] font-semibold text-[var(--ox-flag-deceased)]">
              Deceased
            </span>
          )}
        </div>

        {showDetail && (age || shownIdentifier) && (
          <div className="mt-0.5 flex flex-wrap gap-2 font-[family-name:var(--ox-font-numeric)] text-[length:var(--ox-text-xs)] text-[var(--ox-text-subtle)]">
            {age && <span>{age}</span>}
            {shownIdentifier && (
              <span>
                {identifierLabel} {shownIdentifier}
              </span>
            )}
          </div>
        )}

        {nameAlike && (
          <div className="mt-1 inline-flex items-center gap-1 text-[length:var(--ox-text-xs)] font-medium text-[var(--ox-status-high)]">
            <ShieldAlert className="size-3.5" />
            Similar name in this list — confirm identity
          </div>
        )}
      </div>
    </div>
  );
}