Healthcare

Appointment Slot Picker

A day-and-time slot grid where selecting a time morphs the cell into a confirmed checked state with a spring blur-swap.

Install

npx shadcn@latest add @paragon/appointment-slot-picker

appointment-slot-picker.tsx

"use client";

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

export interface AppointmentDay {
  /** Stable id, e.g. an ISO date. */
  id: string;
  /** Weekday label, e.g. "Mon". */
  weekday: string;
  /** Day-of-month label, e.g. "8". */
  date: string;
  /** Available time slots for the day. */
  times: string[];
}

export interface AppointmentSlotPickerProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "onSelect"> {
  days: AppointmentDay[];
  /** Called when a slot is confirmed. */
  onConfirm?: (dayId: string, time: string) => void;
  /** Disables the confirm morph. */
  static?: boolean;
}

/**
 * A day-by-time slot grid. Picking a day reveals its times; selecting a time
 * morphs that cell into a confirmed state — the label crossfades to a checked
 * pill via a spring blur-swap, and the cell fills with the primary color. One
 * selection at a time; choosing another releases the last. Reduced motion
 * swaps state without the morph.
 */
export function AppointmentSlotPicker({
  days,
  onConfirm,
  static: isStatic = false,
  className,
  ...props
}: AppointmentSlotPickerProps) {
  const [activeDay, setActiveDay] = React.useState(days[0]?.id ?? "");
  const [selected, setSelected] = React.useState<{
    day: string;
    time: string;
  } | null>(null);
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;

  const day = days.find((d) => d.id === activeDay) ?? days[0];

  return (
    <div
      data-slot="appointment-slot-picker"
      className={cn(
        "flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 shadow-border",
        className,
      )}
      {...props}
    >
      {/* Day rail */}
      <div className="grid grid-cols-5 gap-1.5" role="tablist" aria-label="Select a day">
        {days.map((d) => {
          const isActive = d.id === activeDay;
          const hasSel = selected?.day === d.id;
          return (
            <button
              key={d.id}
              type="button"
              role="tab"
              aria-selected={isActive}
              onClick={() => setActiveDay(d.id)}
              className={cn(
                "pressable relative flex flex-col items-center gap-0.5 rounded-lg border py-2 transition-[background-color,border-color,color] duration-(--duration-fast)",
                isActive
                  ? "border-transparent bg-secondary text-foreground"
                  : "border-transparent text-muted-foreground hover:bg-accent/60",
              )}
            >
              <span className="text-[11px] font-medium uppercase">
                {d.weekday}
              </span>
              <span className="text-sm font-semibold tabular-nums">
                {d.date}
              </span>
              {hasSel && (
                <span
                  aria-hidden
                  className="absolute top-1 right-1 size-1.5 rounded-full bg-success"
                />
              )}
            </button>
          );
        })}
      </div>

      {/* Time grid */}
      <div className="grid grid-cols-3 gap-1.5">
        {day?.times.map((time) => {
          const isSelected =
            selected?.day === day.id && selected?.time === time;
          return (
            <button
              key={time}
              type="button"
              aria-pressed={isSelected}
              onClick={() => {
                const next = { day: day.id, time };
                setSelected(next);
                onConfirm?.(day.id, time);
              }}
              className={cn(
                "pressable relative flex h-10 items-center justify-center overflow-hidden rounded-lg border text-sm font-medium tabular-nums transition-[background-color,border-color,color] duration-(--duration-quick) ease-(--ease-out)",
                isSelected
                  ? "border-primary bg-primary text-primary-foreground"
                  : "border-transparent bg-secondary/60 text-foreground hover:bg-secondary",
              )}
            >
              {animate ? (
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    key={isSelected ? "confirmed" : "time"}
                    className="flex items-center gap-1.5"
                    initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
                    animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                    exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
                    transition={{ type: "spring", duration: 0.32, bounce: 0.1 }}
                  >
                    {isSelected && (
                      <Check aria-hidden className="size-3.5" strokeWidth={2.5} />
                    )}
                    {time}
                  </motion.span>
                </AnimatePresence>
              ) : (
                <span className="flex items-center gap-1.5">
                  {isSelected && (
                    <Check aria-hidden className="size-3.5" strokeWidth={2.5} />
                  )}
                  {time}
                </span>
              )}
            </button>
          );
        })}
      </div>

      <p
        aria-live="polite"
        className="min-h-4 text-center text-xs text-muted-foreground"
      >
        {selected
          ? `Confirmed ${
              days.find((d) => d.id === selected.day)?.weekday
            } ${days.find((d) => d.id === selected.day)?.date} at ${selected.time}`
          : "Select a time to confirm"}
      </p>
    </div>
  );
}