Healthcare

Patient Intake Form

A multi-step patient intake with a segmented progress bar, per-step required-field validation, directional blur-slide transitions, and a success confirmation.

Install

npx shadcn@latest add @paragon/patient-intake-form

patient-intake-form.tsx

"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>
  );
}