Healthcare

Prescription Pad

An e-prescribe form with type-ahead drug search, strength/quantity/refills selectors, pharmacy routing, and a sign-and-send confirmation.

Install

npx shadcn@latest add @paragon/prescription-pad

prescription-pad.tsx

"use client";

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

export interface DrugOption {
  name: string;
  strengths: string[];
  form: string;
}

export interface PrescriptionPadProps
  extends Omit<React.ComponentProps<"form">, "onSubmit"> {
  drugs?: DrugOption[];
  pharmacies?: string[];
  onSign?: (rx: {
    drug: string;
    dose: string;
    quantity: number;
    refills: number;
    pharmacy: string;
  }) => void;
}

const DEFAULT_DRUGS: DrugOption[] = [
  { name: "Amoxicillin", strengths: ["250 mg", "500 mg", "875 mg"], form: "Capsule" },
  { name: "Lisinopril", strengths: ["5 mg", "10 mg", "20 mg"], form: "Tablet" },
  { name: "Metformin", strengths: ["500 mg", "850 mg", "1000 mg"], form: "Tablet" },
  { name: "Atorvastatin", strengths: ["10 mg", "20 mg", "40 mg", "80 mg"], form: "Tablet" },
  { name: "Sertraline", strengths: ["25 mg", "50 mg", "100 mg"], form: "Tablet" },
];

const DEFAULT_PHARMACIES = [
  "Cedar Grove Pharmacy — Main St",
  "Riverside Health Rx — 4th Ave",
  "Mail order — 90-day supply",
];

const fieldClass =
  "h-9 w-full rounded-lg border border-input bg-card px-3 text-sm text-card-foreground transition-[border-color,box-shadow] duration-150 ease-(--ease-out) focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 outline-none";

/**
 * An e-prescribe pad: type-ahead drug search filters a listbox, selecting a
 * drug populates the strength/form and enables dose, quantity, refills and
 * pharmacy. Signing summarizes the order. All state changes are instant or
 * short color transitions — a clinical form, no decorative motion.
 */
export function PrescriptionPad({
  drugs = DEFAULT_DRUGS,
  pharmacies = DEFAULT_PHARMACIES,
  onSign,
  className,
  ...props
}: PrescriptionPadProps) {
  const [query, setQuery] = React.useState("");
  const [drug, setDrug] = React.useState<DrugOption | null>(null);
  const [dose, setDose] = React.useState("");
  const [quantity, setQuantity] = React.useState(30);
  const [refills, setRefills] = React.useState(0);
  const [pharmacy, setPharmacy] = React.useState(pharmacies[0]);
  const [signed, setSigned] = React.useState(false);

  const listId = React.useId();
  const matches =
    query.length > 0 && !drug
      ? drugs.filter((d) => d.name.toLowerCase().includes(query.toLowerCase()))
      : [];

  const ready = drug && dose;

  return (
    <form
      data-slot="prescription-pad"
      onSubmit={(e) => {
        e.preventDefault();
        if (!ready) return;
        setSigned(true);
        onSign?.({ drug: `${drug.name} ${dose}`, dose, quantity, refills, pharmacy });
      }}
      className={cn(
        "w-full max-w-sm rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="mb-3 flex items-center gap-2">
        <Pill aria-hidden className="size-4 text-muted-foreground" />
        <h3 className="text-sm font-semibold">New prescription</h3>
      </div>

      <div className="relative">
        <label className="text-xs font-medium text-muted-foreground" htmlFor={`${listId}-drug`}>
          Medication
        </label>
        <div className="relative mt-1">
          <Search
            aria-hidden
            className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
          />
          <input
            id={`${listId}-drug`}
            role="combobox"
            aria-expanded={matches.length > 0}
            aria-controls={listId}
            autoComplete="off"
            placeholder="Search drug name…"
            value={drug ? `${drug.name} · ${drug.form}` : query}
            onChange={(e) => {
              setQuery(e.target.value);
              setDrug(null);
              setDose("");
              setSigned(false);
            }}
            className={cn(fieldClass, "pl-8")}
          />
        </div>
        {matches.length > 0 && (
          <ul
            id={listId}
            role="listbox"
            className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg bg-popover py-1 shadow-overlay"
          >
            {matches.map((d) => (
              <li key={d.name} role="option" aria-selected={false}>
                <button
                  type="button"
                  onClick={() => {
                    setDrug(d);
                    setDose(d.strengths[0]);
                    setQuery("");
                  }}
                  className="flex w-full items-center justify-between px-3 py-1.5 text-left text-sm transition-colors duration-150 hover:bg-accent"
                >
                  <span>{d.name}</span>
                  <span className="text-xs text-muted-foreground">{d.form}</span>
                </button>
              </li>
            ))}
          </ul>
        )}
      </div>

      <div className="mt-3 grid grid-cols-2 gap-3">
        <label className="flex flex-col gap-1">
          <span className="text-xs font-medium text-muted-foreground">Dose</span>
          <select
            disabled={!drug}
            value={dose}
            onChange={(e) => {
              setDose(e.target.value);
              setSigned(false);
            }}
            className={cn(fieldClass, "disabled:opacity-50")}
          >
            {drug ? (
              drug.strengths.map((s) => <option key={s}>{s}</option>)
            ) : (
              <option>—</option>
            )}
          </select>
        </label>
        <label className="flex flex-col gap-1">
          <span className="text-xs font-medium text-muted-foreground">Quantity</span>
          <input
            type="number"
            min={1}
            inputMode="numeric"
            value={quantity}
            onChange={(e) => {
              setQuantity(Math.max(1, Number(e.target.value) || 1));
              setSigned(false);
            }}
            className={cn(fieldClass, "tabular-nums")}
          />
        </label>
      </div>

      <div className="mt-3 grid grid-cols-2 gap-3">
        <label className="flex flex-col gap-1">
          <span className="text-xs font-medium text-muted-foreground">Refills</span>
          <select
            value={refills}
            onChange={(e) => {
              setRefills(Number(e.target.value));
              setSigned(false);
            }}
            className={cn(fieldClass, "tabular-nums")}
          >
            {[0, 1, 2, 3, 5, 11].map((r) => (
              <option key={r} value={r}>
                {r}
              </option>
            ))}
          </select>
        </label>
        <label className="flex flex-col gap-1">
          <span className="text-xs font-medium text-muted-foreground">Pharmacy</span>
          <select
            value={pharmacy}
            onChange={(e) => {
              setPharmacy(e.target.value);
              setSigned(false);
            }}
            className={fieldClass}
          >
            {pharmacies.map((p) => (
              <option key={p}>{p}</option>
            ))}
          </select>
        </label>
      </div>

      <button
        type="submit"
        disabled={!ready || signed}
        className={cn(
          "mt-4 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg text-sm font-medium transition-[background-color,color,scale] duration-150 ease-(--ease-out) focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card outline-none active:not-disabled:scale-[0.98] disabled:opacity-50",
          signed
            ? "bg-success text-success-foreground"
            : "bg-primary text-primary-foreground",
        )}
      >
        {signed ? (
          <>
            <Check className="size-4" /> Sent to pharmacy
          </>
        ) : (
          "Sign & send"
        )}
      </button>
    </form>
  );
}