Medication Timeline
HealthcareA month of medication at a glance: course bars grow in, adherence dots cascade (missed doses in amber), refill flags pulse and interactions annotate the rows they concern.
Medications · last 4 weeks
tap a row for detailstoday
Metformin + Atorvastatin: Generally safe together; monitor for muscle pain and report it early.
Legend: green taken · amber missed · hollow upcoming. Informational — follow your prescriber's instructions.
Install
npx shadcn@latest add https://labs.duku.design/r/medication-timeline.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Medication Timeline component (get_component "medication-timeline", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Mount | Course bars scale in from the left (staggered), then dose dots pop in with a back-out cascade |
| Select a medication | Row expands with adherence stats and missed-dose guidance |
| Check Atorvastatin | A pulsing refill badge warns 4 days before the course runs out |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| medications | Medication[] | — | Name, dose, schedule window, per-day adherence. DEMO_MEDICATIONS ships. |
| interactions | MedicationInteraction[] | — | Pairwise notes rendered under the chart. |
| windowDays / today | number / number | 28 / 20 | Visible window and the today marker. |
Source — registry/default/health/medication-timeline.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { Badge } from "@/registry/default/ui/badge";
/* ------------------------------------------------------------------ */
/* Model */
/* ------------------------------------------------------------------ */
export interface Medication {
name: string;
dose: string;
frequency: string;
/** Day index (0-based) the course starts/ends within the visible window. */
startDay: number;
endDay: number;
/** Adherence per day: taken, missed or upcoming. Index = day - startDay. */
adherence: ("taken" | "missed" | "upcoming")[];
/** Days until refill runs out, if low. */
refillInDays?: number;
}
export interface MedicationInteraction {
a: string;
b: string;
note: string;
}
export interface MedicationTimelineProps
extends React.HTMLAttributes<HTMLDivElement> {
medications: Medication[];
interactions?: MedicationInteraction[];
/** Days in the visible window. Default 28. */
windowDays?: number;
/** 0-based index of "today" within the window. Default 20. */
today?: number;
ref?: React.Ref<HTMLDivElement>;
}
export const DEMO_MEDICATIONS: Medication[] = [
{
name: "Metformin",
dose: "500 mg",
frequency: "2× daily",
startDay: 0,
endDay: 27,
adherence: Array.from({ length: 28 }, (_, i) =>
i > 20 ? "upcoming" : i === 6 || i === 14 ? "missed" : "taken"
),
},
{
name: "Atorvastatin",
dose: "10 mg",
frequency: "1× nightly",
startDay: 4,
endDay: 27,
adherence: Array.from({ length: 24 }, (_, i) =>
i + 4 > 20 ? "upcoming" : i === 9 ? "missed" : "taken"
),
refillInDays: 4,
},
{
name: "Vitamin D3",
dose: "60k IU",
frequency: "weekly",
startDay: 0,
endDay: 27,
adherence: Array.from({ length: 28 }, (_, i) =>
i % 7 !== 0 ? "upcoming" : i > 20 ? "upcoming" : "taken"
),
},
];
export const DEMO_INTERACTIONS: MedicationInteraction[] = [
{
a: "Metformin",
b: "Atorvastatin",
note: "Generally safe together; monitor for muscle pain and report it early.",
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
/**
* A month of medication at a glance: course bars grow in, adherence dots
* cascade (missed doses in warning), refills flag before they run out and
* interactions link the two rows they concern.
*/
export function MedicationTimeline({
medications,
interactions = [],
windowDays = 28,
today = 20,
className,
ref,
...rest
}: MedicationTimelineProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const [selected, setSelected] = React.useState<string | null>(null);
useGSAP(
() => {
const root = rootRef.current;
if (!root) return;
const bars = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-course]"));
const dots = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-dose]"));
if (reduced) {
gsap.set(bars, { scaleX: 1, opacity: 1 });
gsap.set(dots, { scale: 1, opacity: 1 });
return;
}
gsap.fromTo(
bars,
{ scaleX: 0, opacity: 0, transformOrigin: "left center" },
{ scaleX: 1, opacity: 1, duration: 0.6, ease: "power3.out", stagger: 0.12 }
);
gsap.fromTo(
dots,
{ scale: 0, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.3, ease: "back.out(2)", stagger: 0.008, delay: 0.35 }
);
},
{ dependencies: [reduced, medications], scope: rootRef }
);
const pct = (day: number) => (day / (windowDays - 1)) * 100;
const adherenceStats = (m: Medication) => {
const past = m.adherence.filter((a) => a !== "upcoming");
const taken = past.filter((a) => a === "taken").length;
return { taken, past: past.length, pct: past.length ? Math.round((taken / past.length) * 100) : 100 };
};
return (
<div
ref={rootRef}
data-slot="medication-timeline"
className={cn(
"w-full max-w-xl rounded-2xl border border-border bg-card p-4",
className
)}
{...rest}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">Medications · last 4 weeks</h3>
<span className="text-[11px] text-muted-foreground">tap a row for details</span>
</div>
<div className="relative mt-4 flex flex-col gap-5">
{/* today line */}
<span
aria-hidden="true"
className="absolute bottom-0 top-0 w-px bg-info/60"
style={{ left: `${pct(today)}%` }}
/>
<span
className="absolute -top-1 -translate-x-1/2 text-[9px] font-medium uppercase text-info"
style={{ left: `${pct(today)}%` }}
>
today
</span>
{medications.map((m) => {
const stats = adherenceStats(m);
const isSel = selected === m.name;
return (
<button
key={m.name}
type="button"
aria-pressed={isSel}
onClick={() => setSelected((s) => (s === m.name ? null : m.name))}
className={cn(
"group rounded-lg p-1 pt-2 text-left transition-colors duration-200",
isSel ? "bg-muted/70" : "hover:bg-muted/40",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<div className="flex items-baseline justify-between gap-2 px-1">
<span className="text-xs font-medium text-foreground">
{m.name} <span className="text-muted-foreground">{m.dose} · {m.frequency}</span>
</span>
<span
className={cn(
"text-[11px] tabular-nums",
stats.pct >= 90 ? "text-success" : stats.pct >= 75 ? "text-warning" : "text-risk-high"
)}
>
{stats.pct}% adherence
</span>
</div>
{/* course bar + dose dots */}
<div className="relative mt-1.5 h-5 px-1">
<span
data-course
aria-hidden="true"
className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary/15"
style={{ left: `${pct(m.startDay)}%`, width: `${pct(m.endDay) - pct(m.startDay)}%` }}
/>
{m.adherence.map((a, i) => {
const day = m.startDay + i;
if (day >= windowDays) return null;
return (
<span
key={day}
data-dose
aria-hidden="true"
className={cn(
"absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full",
a === "taken" && "bg-success",
a === "missed" && "bg-warning ring-2 ring-warning/30",
a === "upcoming" && "border border-border bg-background"
)}
style={{ left: `${pct(day)}%` }}
/>
);
})}
{m.refillInDays != null ? (
<Badge
variant="outline"
pulse
className="absolute -top-1 right-0 text-[9px] text-warning"
>
Refill in {m.refillInDays}d
</Badge>
) : null}
</div>
{isSel ? (
<p className="mt-1 px-1 text-[11px] leading-4 text-muted-foreground">
{stats.taken}/{stats.past} doses taken.{" "}
{m.adherence.includes("missed")
? "Missed doses shown in amber — don't double up; take the next scheduled dose."
: "No missed doses in this window."}
</p>
) : null}
</button>
);
})}
</div>
{interactions.length > 0 ? (
<div className="mt-4 flex flex-col gap-1.5">
{interactions.map((ix) => (
<p
key={`${ix.a}-${ix.b}`}
className="rounded-lg border border-info/30 bg-info/5 px-2.5 py-1.5 text-[11px] leading-4 text-foreground"
>
<span className="font-semibold">{ix.a} + {ix.b}:</span> {ix.note}
</p>
))}
</div>
) : null}
<p className="mt-3 text-[10px] text-muted-foreground">
Legend: green taken · amber missed · hollow upcoming. Informational —
follow your prescriber's instructions.
</p>
</div>
);
}