Healthcare

Clinical Note

A structured SOAP note editor with auto-growing subjective, objective, assessment, and plan sections, a live completeness bar, and a sign action that blur-swaps to a signed state.

Install

npx shadcn@latest add @paragon/clinical-note

clinical-note.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, FileText } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SoapSection {
  key: "S" | "O" | "A" | "P";
  label: string;
  placeholder: string;
  value?: string;
}

export interface ClinicalNoteProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  patient?: string;
  encounter?: string;
  author?: string;
  sections?: SoapSection[];
  onSign?: (values: Record<string, string>) => void;
  static?: boolean;
}

const DEFAULT_SECTIONS: SoapSection[] = [
  {
    key: "S",
    label: "Subjective",
    placeholder: "Patient-reported symptoms and history…",
    value:
      "58F presents with 3 days of productive cough, low-grade fever, and fatigue. Denies chest pain or dyspnea at rest.",
  },
  {
    key: "O",
    label: "Objective",
    placeholder: "Exam findings, vitals, results…",
    value:
      "T 100.4°F, HR 88, BP 126/78, SpO₂ 97% RA. Lungs: scattered rhonchi, no wheeze. Throat mildly erythematous.",
  },
  {
    key: "A",
    label: "Assessment",
    placeholder: "Diagnoses and clinical reasoning…",
    value:
      "Acute bronchitis, likely viral. Low suspicion for pneumonia given clear breath sounds and normal SpO₂.",
  },
  {
    key: "P",
    label: "Plan",
    placeholder: "Treatment, orders, follow-up…",
    value:
      "Supportive care, guaifenesin PRN, hydration. Return if fever >48h or dyspnea. Follow-up in 1 week.",
  },
];

/**
 * A structured SOAP clinical-note editor. Four labeled sections
 * (subjective / objective / assessment / plan) auto-grow as you type; a live
 * completeness bar reflects filled sections. Signing swaps the footer to a
 * signed, timestamped state via a spring blur-swap. Reduced motion drops the
 * swap animation.
 */
export function ClinicalNote({
  patient = "Dana R. Whitfield · MRN 08823147",
  encounter = "Office visit · Jun 18, 2026",
  author = "Dr. Priya Nair, MD",
  sections = DEFAULT_SECTIONS,
  onSign,
  static: isStatic = false,
  className,
  ...props
}: ClinicalNoteProps) {
  const reduced = useReducedMotion();
  const [values, setValues] = React.useState<Record<string, string>>(() =>
    Object.fromEntries(sections.map((s) => [s.key, s.value ?? ""])),
  );
  const [signed, setSigned] = React.useState(false);
  const animate = !isStatic && !reduced;

  const filled = sections.filter((s) => (values[s.key] ?? "").trim()).length;
  const pct = filled / sections.length;

  return (
    <div
      data-slot="clinical-note"
      className={cn(
        "w-full max-w-lg overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <header className="flex items-start gap-2.5 border-b border-border p-4">
        <span
          aria-hidden
          className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-secondary-foreground"
        >
          <FileText className="size-4" />
        </span>
        <div className="min-w-0 flex-1">
          <p className="truncate text-sm font-medium tabular-nums">{patient}</p>
          <p className="truncate text-xs text-muted-foreground">{encounter}</p>
        </div>
        <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
          {filled}/{sections.length}
        </span>
      </header>

      <div className="h-1 overflow-hidden bg-secondary">
        <div
          className="h-full origin-left bg-primary"
          style={{
            transform: `scaleX(${pct})`,
            transition: animate ? "transform 250ms var(--ease-out)" : undefined,
          }}
        />
      </div>

      <div className="flex flex-col gap-4 p-4">
        {sections.map((s) => (
          <div key={s.key} className="flex gap-3">
            <span
              aria-hidden
              className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary text-xs font-semibold text-secondary-foreground"
            >
              {s.key}
            </span>
            <label className="min-w-0 flex-1">
              <span className="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
                {s.label}
              </span>
              <textarea
                rows={2}
                disabled={signed}
                placeholder={s.placeholder}
                value={values[s.key] ?? ""}
                onChange={(e) =>
                  setValues((v) => ({ ...v, [s.key]: e.target.value }))
                }
                className="mt-1 field-sizing-content min-h-[3.5rem] w-full resize-none rounded-lg border border-input bg-card px-3 py-2 text-sm leading-relaxed 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 disabled:opacity-70"
              />
            </label>
          </div>
        ))}
      </div>

      <footer className="flex items-center justify-between gap-3 border-t border-border p-4">
        <AnimatePresence mode="popLayout" initial={false}>
          {signed ? (
            <motion.p
              key="signed"
              initial={animate ? { opacity: 0, filter: "blur(4px)" } : false}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className="flex items-center gap-1.5 text-xs text-success"
            >
              <Check className="size-3.5" />
              Signed by {author}
            </motion.p>
          ) : (
            <motion.p
              key="draft"
              initial={animate ? { opacity: 0, filter: "blur(4px)" } : false}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              transition={{ duration: 0.15 }}
              className="text-xs text-muted-foreground"
            >
              Draft · {author}
            </motion.p>
          )}
        </AnimatePresence>
        <button
          type="button"
          disabled={signed || filled < sections.length}
          onClick={() => {
            setSigned(true);
            onSign?.(values);
          }}
          className={cn(
            "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 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.97] disabled:opacity-50",
            signed
              ? "bg-success text-success-foreground"
              : "bg-primary text-primary-foreground",
          )}
        >
          {signed ? "Signed" : "Sign note"}
        </button>
      </footer>
    </div>
  );
}