A vertical care-episode timeline with severity-coded nodes; the connecting rail draws in once on view and the episodes stagger in.
npx shadcn@latest add @paragon/patient-timeline"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>
);
}