Healthcare

Patient Timeline

A vertical care-episode timeline with severity-coded nodes; the connecting rail draws in once on view and the episodes stagger in.

Install

npx shadcn@latest add @paragon/patient-timeline

patient-timeline.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type EpisodeSeverity = "routine" | "moderate" | "urgent" | "resolved";

export interface CareEpisode {
  id: string;
  /** Short date label, e.g. "Mar 12". */
  date: string;
  title: string;
  /** Care setting or clinician, e.g. "Cardiology · Dr. Reyes". */
  detail?: string;
  severity: EpisodeSeverity;
}

export interface PatientTimelineProps extends React.ComponentProps<"ol"> {
  episodes: CareEpisode[];
  /** Disables the rail draw and node stagger. */
  static?: boolean;
}

const severityMeta: Record<
  EpisodeSeverity,
  { node: string; ring: string; badge: string; label: string }
> = {
  routine: {
    node: "bg-muted-foreground/50",
    ring: "ring-muted-foreground/15",
    badge: "bg-secondary text-muted-foreground",
    label: "Routine",
  },
  moderate: {
    node: "bg-warning",
    ring: "ring-warning/20",
    badge: "bg-warning/15 text-warning",
    label: "Moderate",
  },
  urgent: {
    node: "bg-destructive",
    ring: "ring-destructive/20",
    badge: "bg-destructive/15 text-destructive",
    label: "Urgent",
  },
  resolved: {
    node: "bg-success",
    ring: "ring-success/20",
    badge: "bg-success/15 text-success",
    label: "Resolved",
  },
};

/**
 * A vertical care-episode timeline. On first view the connecting rail draws in
 * top-to-bottom once (scaleY from the top origin), then each severity-coded
 * node and its card stagger in with the house enter language. Reduced motion
 * shows everything in place. The rail is decorative; the list is a semantic
 * ordered list.
 */
export function PatientTimeline({
  episodes,
  static: isStatic = false,
  className,
  ...props
}: PatientTimelineProps) {
  const ref = React.useRef<HTMLOListElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -40px 0px" });
  const animate = !isStatic && !reducedMotion;
  const shown = !animate || inView;

  return (
    <ol
      ref={ref}
      data-slot="patient-timeline"
      className={cn("relative flex w-full flex-col", className)}
      {...props}
    >
      {/* Rail — draws in once, top origin */}
      <span
        aria-hidden
        className="pointer-events-none absolute top-2 bottom-2 left-[7px] w-px bg-border"
        style={{
          transformOrigin: "top",
          transform: shown ? "scaleY(1)" : "scaleY(0)",
          transition: animate
            ? "transform 600ms var(--ease-out)"
            : undefined,
        }}
      />
      {episodes.map((ep, i) => {
        const meta = severityMeta[ep.severity];
        return (
          <li
            key={ep.id}
            className="relative flex gap-4 pb-5 last:pb-0"
            style={{
              opacity: shown ? 1 : 0,
              transform: shown ? "translateY(0)" : "translateY(12px)",
              filter: shown ? "blur(0px)" : "blur(4px)",
              transition: animate
                ? `opacity 320ms var(--ease-out) ${180 + i * 70}ms, transform 320ms var(--ease-out) ${180 + i * 70}ms, filter 320ms var(--ease-out) ${180 + i * 70}ms`
                : undefined,
            }}
          >
            <span
              aria-hidden
              className={cn(
                "relative z-10 mt-1 size-3.5 shrink-0 rounded-full ring-4 ring-background",
              )}
            >
              <span
                className={cn(
                  "block size-full rounded-full ring-4",
                  meta.node,
                  meta.ring,
                )}
              />
            </span>
            <div className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
              <div className="flex items-center gap-2">
                <span className="text-xs text-muted-foreground tabular-nums">
                  {ep.date}
                </span>
                <span
                  className={cn(
                    "rounded-full px-1.5 py-0.5 text-[11px] font-medium",
                    meta.badge,
                  )}
                >
                  {meta.label}
                </span>
              </div>
              <span className="text-sm font-medium text-foreground">
                {ep.title}
              </span>
              {ep.detail && (
                <span className="text-xs text-muted-foreground">
                  {ep.detail}
                </span>
              )}
            </div>
          </li>
        );
      })}
    </ol>
  );
}