Healthcare

Immunization Record

A vaccine history timeline with per-dose status nodes for complete, scheduled, due, and overdue entries, surfacing outstanding immunizations at a glance.

Install

npx shadcn@latest add @paragon/immunization-record

immunization-record.tsx

"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>
  );
}