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.
npx shadcn@latest add @paragon/clinical-note"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>
);
}