A day-and-time slot grid where selecting a time morphs the cell into a confirmed checked state with a spring blur-swap.
npx shadcn@latest add @paragon/appointment-slot-picker"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>
);
}