A provider calendar with a weekday strip, bookable time slots that stagger in, and a confirm button that blur-swaps into a booked state.
npx shadcn@latest add @paragon/appointment-scheduler"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>
);
}