Healthcare

Appointment Scheduler

A provider calendar with a weekday strip, bookable time slots that stagger in, and a confirm button that blur-swaps into a booked state.

Install

npx shadcn@latest add @paragon/appointment-scheduler

appointment-scheduler.tsx

"use client";

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

export interface SchedulerDay {
  /** Short weekday label, e.g. "Mon". */
  weekday: string;
  /** Day of month. */
  date: number;
  /** Bookable times for this day, e.g. ["9:00 AM"]. */
  slots: string[];
}

export interface AppointmentSchedulerProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  provider?: string;
  specialty?: string;
  days?: SchedulerDay[];
  /** Called with the chosen day index and time on confirm. */
  onConfirm?: (day: SchedulerDay, time: string) => void;
  static?: boolean;
}

const DEFAULT_DAYS: SchedulerDay[] = [
  { weekday: "Mon", date: 14, slots: ["9:00 AM", "10:30 AM", "2:00 PM"] },
  { weekday: "Tue", date: 15, slots: ["8:30 AM", "1:15 PM"] },
  { weekday: "Wed", date: 16, slots: [] },
  { weekday: "Thu", date: 17, slots: ["9:45 AM", "11:00 AM", "3:30 PM", "4:15 PM"] },
  { weekday: "Fri", date: 18, slots: ["10:00 AM", "2:45 PM"] },
];

/**
 * A provider calendar: a weekday strip selects a day, its bookable times fade
 * in, and picking one enables a confirm button that blur-swaps to a booked
 * state. Selection is transitions/springs only; reduced motion drops the swap.
 */
export function AppointmentScheduler({
  provider = "Dr. Priya Nair, MD",
  specialty = "Family Medicine",
  days = DEFAULT_DAYS,
  onConfirm,
  static: isStatic = false,
  className,
  ...props
}: AppointmentSchedulerProps) {
  const reduced = useReducedMotion();
  const firstOpen = days.findIndex((d) => d.slots.length > 0);
  const [dayIdx, setDayIdx] = React.useState(firstOpen < 0 ? 0 : firstOpen);
  const [time, setTime] = React.useState<string | null>(null);
  const [booked, setBooked] = React.useState(false);

  const day = days[dayIdx];
  const animate = !isStatic && !reduced;

  const selectDay = (i: number) => {
    setDayIdx(i);
    setTime(null);
    setBooked(false);
  };

  return (
    <div
      data-slot="appointment-scheduler"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-2.5">
        <span
          aria-hidden
          className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-secondary-foreground"
        >
          <Calendar className="size-4" />
        </span>
        <div className="min-w-0">
          <p className="truncate text-sm font-medium">{provider}</p>
          <p className="truncate text-xs text-muted-foreground">{specialty}</p>
        </div>
      </div>

      <div
        role="tablist"
        aria-label="Choose a day"
        className="mt-4 grid grid-cols-5 gap-1.5"
      >
        {days.map((d, i) => {
          const open = d.slots.length > 0;
          const selected = i === dayIdx;
          return (
            <button
              key={d.weekday}
              role="tab"
              aria-selected={selected}
              disabled={!open}
              onClick={() => selectDay(i)}
              className={cn(
                "flex flex-col items-center gap-0.5 rounded-lg py-2 text-xs transition-[background-color,color,box-shadow] 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 disabled:opacity-40",
                selected
                  ? "bg-primary text-primary-foreground"
                  : open
                    ? "bg-secondary text-secondary-foreground hover:bg-accent"
                    : "text-muted-foreground",
              )}
            >
              <span className="font-medium">{d.weekday}</span>
              <span className="text-sm font-semibold tabular-nums">{d.date}</span>
            </button>
          );
        })}
      </div>

      <div className="mt-4 min-h-24">
        {day.slots.length > 0 ? (
          <div className="grid grid-cols-3 gap-1.5">
            {day.slots.map((t, i) => {
              const active = t === time;
              return (
                <motion.button
                  key={`${dayIdx}-${t}`}
                  type="button"
                  initial={animate ? { opacity: 0, y: 6 } : false}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: 0.2,
                    ease: [0.22, 1, 0.36, 1],
                    delay: animate ? i * 0.03 : 0,
                  }}
                  onClick={() => {
                    setTime(t);
                    setBooked(false);
                  }}
                  className={cn(
                    "rounded-lg py-2 text-xs font-medium tabular-nums transition-[background-color,color,box-shadow] 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
                      ? "bg-primary text-primary-foreground"
                      : "shadow-border hover:shadow-border-hover",
                  )}
                >
                  {t}
                </motion.button>
              );
            })}
          </div>
        ) : (
          <p className="py-6 text-center text-sm text-muted-foreground">
            No openings this day.
          </p>
        )}
      </div>

      <button
        type="button"
        disabled={!time || booked}
        onClick={() => {
          setBooked(true);
          if (time) onConfirm?.(day, time);
        }}
        className={cn(
          "mt-2 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg text-sm font-medium transition-[background-color,color,box-shadow,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 disabled:opacity-50",
          booked
            ? "bg-success text-success-foreground"
            : "bg-primary text-primary-foreground",
          !isStatic && "active:not-disabled:scale-[0.98]",
        )}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {booked ? (
            <motion.span
              key="booked"
              className="inline-flex items-center gap-2"
              initial={animate ? { opacity: 0, scale: 0.6, filter: "blur(4px)" } : false}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              <Check className="size-4" />
              Booked {day.weekday} {time}
            </motion.span>
          ) : (
            <motion.span
              key="confirm"
              initial={animate ? { opacity: 0, filter: "blur(4px)" } : false}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              transition={{ duration: 0.15 }}
            >
              {time ? `Confirm ${time}` : "Select a time"}
            </motion.span>
          )}
        </AnimatePresence>
      </button>
    </div>
  );
}