Identity Token
StablePatientPrimitive · 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-tokenFirst 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.
Restricted and deceased are words, not icons a reader has to know. A missing name is not a blank.
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
| Prop | Type | Default |
|---|---|---|
| asOf | Date | undefined | new Date() |
| avatarOnly Avatar only. The name still reaches assistive technology. | boolean | undefined | false |
| 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 | undefined | false |
| 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 | undefined | false |
| 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 | undefined | false |
| showDetail Show age and secondary identifier under the name. | boolean | undefined | true |
| size | 'sm' | 'md' | 'lg' | undefined | "md" |
Guidance
When to use it, and when not to.
DECISION SURFACE / 6 RULES
Recommended context
Use it
- 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
- 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
- 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 sourceHide 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>
);
}