A multi-step patient intake with a segmented progress bar, per-step required-field validation, directional blur-slide transitions, and a success confirmation.
npx shadcn@latest add @paragon/patient-intake-form"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";
interface IntakeField {
name: string;
label: string;
type?: "text" | "email" | "tel" | "date";
required?: boolean;
placeholder?: string;
}
export interface IntakeStep {
title: string;
fields: IntakeField[];
}
export interface PatientIntakeFormProps
extends Omit<React.ComponentProps<"form">, "onSubmit"> {
steps?: IntakeStep[];
onComplete?: (values: Record<string, string>) => void;
static?: boolean;
}
const DEFAULT_STEPS: IntakeStep[] = [
{
title: "Patient",
fields: [
{ name: "firstName", label: "First name", required: true, placeholder: "Jordan" },
{ name: "lastName", label: "Last name", required: true, placeholder: "Ellis" },
{ name: "dob", label: "Date of birth", type: "date", required: true },
],
},
{
title: "Contact",
fields: [
{ name: "email", label: "Email", type: "email", required: true, placeholder: "jordan@email.com" },
{ name: "phone", label: "Mobile phone", type: "tel", required: true, placeholder: "(555) 012-3456" },
],
},
{
title: "Insurance",
fields: [
{ name: "carrier", label: "Insurance carrier", required: true, placeholder: "United Healthcare" },
{ name: "memberId", label: "Member ID", required: true, placeholder: "UHC0938471" },
],
},
];
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";
/**
* A multi-step patient intake with a segmented progress bar, per-step required
* validation, and directional blur-slide transitions between steps. The final
* step swaps to a success confirmation. Reduced motion drops the slide, keeps
* the crossfade.
*/
export function PatientIntakeForm({
steps = DEFAULT_STEPS,
onComplete,
static: isStatic = false,
className,
...props
}: PatientIntakeFormProps) {
const reduced = useReducedMotion();
const [step, setStep] = React.useState(0);
const [dir, setDir] = React.useState(1);
const [values, setValues] = React.useState<Record<string, string>>({});
const [errors, setErrors] = React.useState<Record<string, boolean>>({});
const [done, setDone] = React.useState(false);
const animate = !isStatic && !reduced;
const current = steps[step];
const isLast = step === steps.length - 1;
const validate = () => {
const next: Record<string, boolean> = {};
for (const f of current.fields) {
if (f.required && !values[f.name]?.trim()) next[f.name] = true;
}
setErrors(next);
return Object.keys(next).length === 0;
};
const go = (delta: number) => {
if (delta > 0 && !validate()) return;
if (delta > 0 && isLast) {
setDone(true);
onComplete?.(values);
return;
}
setDir(delta);
setStep((s) => Math.min(steps.length - 1, Math.max(0, s + delta)));
};
const slide = animate
? {
initial: { opacity: 0, x: dir * 24, filter: "blur(4px)" },
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: { opacity: 0, x: dir * -24, filter: "blur(4px)" },
}
: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
return (
<form
data-slot="patient-intake-form"
onSubmit={(e) => {
e.preventDefault();
go(1);
}}
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
{step > 0 && !done && (
<button
type="button"
aria-label="Previous step"
onClick={() => go(-1)}
className="-ml-1 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring outline-none"
>
<ChevronLeft className="size-4" />
</button>
)}
<p className="text-xs font-medium text-muted-foreground tabular-nums">
{done ? "Complete" : `Step ${step + 1} of ${steps.length} · ${current.title}`}
</p>
</div>
<div className="mt-2.5 flex gap-1.5" aria-hidden>
{steps.map((_, i) => (
<span
key={i}
className="h-1 flex-1 overflow-hidden rounded-full bg-secondary"
>
<span
className="block h-full w-full origin-left rounded-full bg-primary"
style={{
transform: `scaleX(${done || i < step ? 1 : i === step ? 0.5 : 0})`,
transition: animate ? "transform 300ms var(--ease-out)" : undefined,
}}
/>
</span>
))}
</div>
<div className="relative mt-4 min-h-40">
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
{done ? (
<motion.div
key="done"
{...slide}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="flex flex-col items-center justify-center py-6 text-center"
>
<span
aria-hidden
className="flex size-11 items-center justify-center rounded-full bg-success text-success-foreground"
>
<Check className="size-5" />
</span>
<p className="mt-3 text-sm font-medium">Intake submitted</p>
<p className="mt-1 text-xs text-muted-foreground">
Your care team will review before your visit.
</p>
</motion.div>
) : (
<motion.div
key={step}
{...slide}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="flex flex-col gap-3"
>
{current.fields.map((f) => (
<label key={f.name} className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
{f.label}
{f.required && <span className="text-destructive"> *</span>}
</span>
<input
type={f.type ?? "text"}
placeholder={f.placeholder}
value={values[f.name] ?? ""}
aria-invalid={errors[f.name] || undefined}
onChange={(e) => {
setValues((v) => ({ ...v, [f.name]: e.target.value }));
if (errors[f.name]) setErrors((x) => ({ ...x, [f.name]: false }));
}}
className={cn(
fieldClass,
errors[f.name] &&
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/40",
)}
/>
{errors[f.name] && (
<span className="text-[11px] text-destructive">Required</span>
)}
</label>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{!done && (
<button
type="submit"
className="mt-4 inline-flex h-9 w-full items-center justify-center rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-[background-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:scale-[0.98]"
>
{isLast ? "Submit intake" : "Continue"}
</button>
)}
</form>
);
}