Healthcare

Referral Tracker

Referral cards that advance through a four-node stage rail from requested to seen, with urgent tinting and a spring-eased fill as each referral steps forward.

Install

npx shadcn@latest add @paragon/referral-tracker

referral-tracker.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ArrowRight, Stethoscope } from "lucide-react";
import { cn } from "@/lib/utils";

const STAGES = ["Requested", "Authorized", "Scheduled", "Seen"] as const;
export type ReferralStage = (typeof STAGES)[number];

export interface Referral {
  id: string;
  patient: string;
  specialty: string;
  toProvider: string;
  requested: string;
  stage: ReferralStage;
  /** Optional urgency tag. */
  priority?: "routine" | "urgent";
}

export interface ReferralTrackerProps extends React.ComponentProps<"div"> {
  referrals?: Referral[];
  static?: boolean;
}

const DEFAULT_REFERRALS: Referral[] = [
  { id: "1", patient: "Dana Whitfield", specialty: "Cardiology", toProvider: "Dr. Okafor", requested: "Jun 10", stage: "Scheduled", priority: "routine" },
  { id: "2", patient: "Marcus Delgado", specialty: "Nephrology", toProvider: "Dr. Vásquez", requested: "Jun 12", stage: "Authorized", priority: "urgent" },
  { id: "3", patient: "Jordan Ellis", specialty: "Dermatology", toProvider: "Dr. Rahman", requested: "Jun 14", stage: "Requested" },
  { id: "4", patient: "Camila Ortega", specialty: "Orthopedics", toProvider: "Dr. Ortiz", requested: "Jun 08", stage: "Seen", priority: "routine" },
];

/**
 * A referral tracker: each card advances through a four-node stage rail
 * (requested → authorized → scheduled → seen). Advancing steps the active
 * node forward with a spring-eased fill; urgent referrals carry a tint.
 * Reduced motion drops the node transition to an instant color change.
 */
export function ReferralTracker({
  referrals: initial = DEFAULT_REFERRALS,
  static: isStatic = false,
  className,
  ...props
}: ReferralTrackerProps) {
  const reduced = useReducedMotion();
  const [referrals, setReferrals] = React.useState(initial);
  const animate = !isStatic && !reduced;

  const advance = (id: string) =>
    setReferrals((prev) =>
      prev.map((r) => {
        if (r.id !== id) return r;
        const idx = STAGES.indexOf(r.stage);
        return { ...r, stage: STAGES[Math.min(STAGES.length - 1, idx + 1)] };
      }),
    );

  return (
    <div
      data-slot="referral-tracker"
      className={cn("flex w-full max-w-md flex-col gap-2.5", className)}
      {...props}
    >
      {referrals.map((r) => {
        const idx = STAGES.indexOf(r.stage);
        const complete = r.stage === "Seen";
        return (
          <article
            key={r.id}
            className="rounded-xl bg-card p-3.5 text-card-foreground shadow-border"
          >
            <div className="flex items-start gap-2.5">
              <span
                aria-hidden
                className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-secondary-foreground"
              >
                <Stethoscope className="size-4" />
              </span>
              <div className="min-w-0 flex-1">
                <div className="flex items-baseline gap-2">
                  <p className="truncate text-sm font-medium">{r.patient}</p>
                  {r.priority === "urgent" && (
                    <span className="shrink-0 rounded bg-destructive/12 px-1.5 py-0.5 text-[10px] font-medium text-destructive">
                      Urgent
                    </span>
                  )}
                </div>
                <p className="text-xs text-muted-foreground">
                  {r.specialty}{r.toProvider} ·{" "}
                  <span className="tabular-nums">req {r.requested}</span>
                </p>
              </div>
              {!complete && (
                <button
                  type="button"
                  aria-label={`Advance ${r.patient}'s referral`}
                  onClick={() => advance(r.id)}
                  className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring outline-none"
                >
                  <ArrowRight className="size-4" />
                </button>
              )}
            </div>

            <div className="mt-3 flex items-center gap-1.5">
              {STAGES.map((s, i) => {
                const reached = i <= idx;
                return (
                  <React.Fragment key={s}>
                    <span className="flex flex-col items-center gap-1">
                      <span
                        className={cn(
                          "size-2.5 rounded-full",
                          reached
                            ? complete && i === idx
                              ? "bg-success"
                              : "bg-primary"
                            : "bg-secondary",
                        )}
                        style={{
                          transition: animate
                            ? "background-color 300ms var(--ease-out)"
                            : undefined,
                        }}
                      />
                    </span>
                    {i < STAGES.length - 1 && (
                      <span className="h-0.5 flex-1 overflow-hidden rounded-full bg-secondary">
                        <span
                          className="block h-full w-full origin-left rounded-full bg-primary"
                          style={{
                            transform: `scaleX(${i < idx ? 1 : 0})`,
                            transition: animate
                              ? "transform 350ms var(--ease-out)"
                              : undefined,
                          }}
                        />
                      </span>
                    )}
                  </React.Fragment>
                );
              })}
            </div>
            <div className="mt-1.5 flex justify-between text-[10px] text-muted-foreground">
              {STAGES.map((s) => (
                <span key={s} className="flex-1 text-center first:text-left last:text-right">
                  {s}
                </span>
              ))}
            </div>
          </article>
        );
      })}
    </div>
  );
}