A vaccine history timeline with per-dose status nodes for complete, scheduled, due, and overdue entries, surfacing outstanding immunizations at a glance.
npx shadcn@latest add @paragon/immunization-record"use client";
import * as React from "react";
import { Check, Clock, AlertCircle, Syringe } from "lucide-react";
import { cn } from "@/lib/utils";
export type ImmunizationStatus = "complete" | "due" | "overdue" | "scheduled";
export interface Immunization {
vaccine: string;
/** Dose descriptor, e.g. "Dose 2 of 2" or "Booster". */
dose?: string;
/** Administered or target date. */
date: string;
status: ImmunizationStatus;
}
export interface ImmunizationRecordProps extends React.ComponentProps<"div"> {
patient?: string;
immunizations?: Immunization[];
}
const STATUS_META: Record<
ImmunizationStatus,
{ label: string; node: string; text: string; Icon: typeof Check }
> = {
complete: { label: "Complete", node: "bg-success text-success-foreground", text: "text-muted-foreground", Icon: Check },
scheduled: { label: "Scheduled", node: "bg-primary text-primary-foreground", text: "text-foreground", Icon: Clock },
due: { label: "Due", node: "bg-warning text-warning-foreground", text: "text-warning", Icon: Clock },
overdue: { label: "Overdue", node: "bg-destructive text-destructive-foreground", text: "text-destructive", Icon: AlertCircle },
};
const DEFAULT: Immunization[] = [
{ vaccine: "Influenza (quadrivalent)", dose: "Annual", date: "Oct 2025", status: "complete" },
{ vaccine: "Tdap", dose: "Booster", date: "Mar 2024", status: "complete" },
{ vaccine: "COVID-19", dose: "Updated 2025–26", date: "Sep 2025", status: "complete" },
{ vaccine: "Pneumococcal (PCV20)", dose: "Dose 1 of 1", date: "Due now", status: "due" },
{ vaccine: "Shingles (RZV)", dose: "Dose 2 of 2", date: "Overdue 3 mo", status: "overdue" },
{ vaccine: "Hepatitis B", dose: "Dose 3 of 3", date: "Jan 2026", status: "scheduled" },
];
/**
* A vaccine history rail. Each entry sits on a vertical timeline with a status
* node — complete, scheduled, due, or overdue — and its dose and date. Due and
* overdue rows carry a colored tint so gaps surface at a glance. Static
* composition — a record, read not animated.
*/
export function ImmunizationRecord({
patient = "Dana R. Whitfield",
immunizations = DEFAULT,
className,
...props
}: ImmunizationRecordProps) {
const outstanding = immunizations.filter(
(i) => i.status === "due" || i.status === "overdue",
).length;
return (
<div
data-slot="immunization-record"
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2.5">
<span
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-secondary-foreground"
>
<Syringe className="size-4" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-medium">Immunizations</h3>
<p className="truncate text-xs text-muted-foreground">{patient}</p>
</div>
{outstanding > 0 && (
<span className="shrink-0 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning tabular-nums">
{outstanding} due
</span>
)}
</div>
<ol className="mt-4 flex flex-col">
{immunizations.map((im, i) => {
const meta = STATUS_META[im.status];
const last = i === immunizations.length - 1;
const Icon = meta.Icon;
return (
<li key={`${im.vaccine}-${i}`} className="flex gap-3">
<div className="flex flex-col items-center">
<span
aria-hidden
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full",
meta.node,
)}
>
<Icon className="size-3.5" />
</span>
{!last && (
<span aria-hidden className="w-px flex-1 bg-border" />
)}
</div>
<div
className={cn(
"min-w-0 flex-1 pb-4",
last && "pb-0",
)}
>
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-medium">{im.vaccine}</p>
<span className={cn("shrink-0 text-xs font-medium", meta.text)}>
{meta.label}
</span>
</div>
<p className="text-xs text-muted-foreground tabular-nums">
{im.dose ? `${im.dose} · ` : ""}
{im.date}
</p>
</div>
</li>
);
})}
</ol>
</div>
);
}