Healthcare

Medication Schedule

A day's dose slots by time with taken, due, upcoming, and missed states; checking a due dose draws a check and settles the row to a muted taken look.

Install

npx shadcn@latest add @paragon/medication-schedule

medication-schedule.tsx

"use client";

import * as React from "react";
import { Clock, Pill } from "lucide-react";
import { cn } from "@/lib/utils";

export type DoseState = "taken" | "due" | "upcoming" | "missed";

export interface DoseSlot {
  id: string;
  /** Display time, e.g. "8:00 AM". */
  time: string;
  medication: string;
  /** Dosage detail, e.g. "500 mg · 1 tablet". */
  detail?: string;
  state: DoseState;
}

export interface MedicationScheduleProps
  extends Omit<React.ComponentProps<"ul">, "onChange"> {
  slots: DoseSlot[];
  /** Fires with the dose id when a due dose is checked off. */
  onTake?: (id: string) => void;
  /** Disables the check-draw and settle motion. */
  static?: boolean;
}

const stateMeta: Record<
  DoseState,
  { ring: string; label: string; labelClass: string }
> = {
  taken: {
    ring: "border-success bg-success text-success-foreground",
    label: "Taken",
    labelClass: "text-success",
  },
  due: {
    ring: "border-primary bg-transparent text-transparent hover:bg-accent",
    label: "Due now",
    labelClass: "text-foreground",
  },
  upcoming: {
    ring: "border-input bg-transparent text-transparent",
    label: "Upcoming",
    labelClass: "text-muted-foreground",
  },
  missed: {
    ring: "border-destructive/40 bg-destructive/10 text-destructive",
    label: "Missed",
    labelClass: "text-destructive",
  },
};

/**
 * A day's dose slots as a vertical list, each with a taken / due / upcoming /
 * missed state. Checking a due dose draws the check (pathLength +
 * stroke-dashoffset transition) and settles the row to a muted, taken look —
 * all CSS transitions, so re-toggling retargets cleanly. Reduced motion keeps
 * the color settle and drops the draw. Uncontrolled by default; pass `state`
 * and `onTake` to control it.
 */
export function MedicationSchedule({
  slots,
  onTake,
  static: isStatic = false,
  className,
  ...props
}: MedicationScheduleProps) {
  const [taken, setTaken] = React.useState<Set<string>>(() => new Set());

  const effectiveState = (slot: DoseSlot): DoseState =>
    slot.state === "due" && taken.has(slot.id) ? "taken" : slot.state;

  return (
    <ul
      data-slot="medication-schedule"
      className={cn("flex w-full flex-col", className)}
      {...props}
    >
      {slots.map((slot, i) => {
        const state = effectiveState(slot);
        const meta = stateMeta[state];
        const isTaken = state === "taken";
        const interactive = slot.state === "due";
        const last = i === slots.length - 1;

        const dot = (
          <span
            aria-hidden
            className={cn(
              "relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full border",
              meta.ring,
              !isStatic &&
                "transition-[background-color,border-color] duration-(--duration-quick) ease-out",
            )}
          >
            {(isTaken || state === "missed") && (
              <svg viewBox="0 0 14 14" fill="none" className="size-3.5">
                {isTaken ? (
                  <path
                    d="M3 7.4 5.6 10 11 4"
                    pathLength={1}
                    stroke="currentColor"
                    strokeWidth={1.8}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeDasharray={1}
                    style={{
                      strokeDashoffset: isTaken ? 0 : 1,
                      transition: isStatic
                        ? undefined
                        : "stroke-dashoffset 220ms var(--ease-out) 60ms",
                    }}
                  />
                ) : (
                  <path
                    d="M4 4 10 10 M10 4 4 10"
                    stroke="currentColor"
                    strokeWidth={1.6}
                    strokeLinecap="round"
                  />
                )}
              </svg>
            )}
          </span>
        );

        const body = (
          <span className="flex min-w-0 flex-1 flex-col gap-0.5 text-left">
            <span className="flex items-center gap-1.5">
              <Pill
                aria-hidden
                className={cn(
                  "size-3.5 shrink-0",
                  isTaken ? "text-muted-foreground" : "text-muted-foreground",
                )}
              />
              <span
                className={cn(
                  "truncate text-sm font-medium",
                  isTaken
                    ? "text-muted-foreground line-through decoration-muted-foreground/40"
                    : "text-foreground",
                  !isStatic && "transition-colors duration-(--duration-base)",
                )}
              >
                {slot.medication}
              </span>
            </span>
            {slot.detail && (
              <span className="pl-5 text-xs text-muted-foreground">
                {slot.detail}
              </span>
            )}
          </span>
        );

        return (
          <li key={slot.id} className="relative flex gap-3 pb-1">
            {/* connective rail — 76px = time col (44) + gap (12) + row pad (8) + half dot (12) */}
            {!last && (
              <span
                aria-hidden
                className="absolute top-7 bottom-0 left-[75.5px] w-px bg-border"
              />
            )}
            <span className="flex w-11 shrink-0 items-center justify-end gap-1 pt-1 text-[11px] text-muted-foreground tabular-nums">
              {state === "due" && !isTaken && (
                <Clock aria-hidden className="size-3 text-primary" />
              )}
              <span className="whitespace-nowrap">{slot.time}</span>
            </span>
            {interactive ? (
              <button
                type="button"
                aria-pressed={isTaken}
                onClick={() => {
                  setTaken((prev) => {
                    const next = new Set(prev);
                    if (next.has(slot.id)) next.delete(slot.id);
                    else next.add(slot.id);
                    return next;
                  });
                  if (!taken.has(slot.id)) onTake?.(slot.id);
                }}
                className="flex flex-1 items-start gap-3 rounded-lg px-2 py-2 text-left transition-colors duration-(--duration-fast) hover:bg-accent/60"
              >
                {dot}
                {body}
                <span
                  className={cn(
                    "shrink-0 self-center text-[11px] font-medium",
                    meta.labelClass,
                  )}
                >
                  {isTaken ? stateMeta.taken.label : meta.label}
                </span>
              </button>
            ) : (
              <div className="flex flex-1 items-start gap-3 px-2 py-2">
                {dot}
                {body}
                <span
                  className={cn(
                    "shrink-0 self-center text-[11px] font-medium",
                    meta.labelClass,
                  )}
                >
                  {meta.label}
                </span>
              </div>
            )}
          </li>
        );
      })}
    </ul>
  );
}