Calendar Booking
Original · freebooking calendar with time slots
byWeb InnoventixReact + Tailwind
calendarbookingcalendars
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/calendar-booking.jsoncalendar-booking.tsx
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/* ---------------------------------- data ---------------------------------- */
const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const WEEKDAY_LONG = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MONTH_LONG = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const DURATIONS: { value: number; label: string }[] = [
{ value: 15, label: "15 min" },
{ value: 30, label: "30 min" },
{ value: 45, label: "45 min" },
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_MONTHS_AHEAD = 3;
type DayState = "past" | "weekend" | "available";
type Slot = {
key: string;
label: string;
minutes: number;
booked: boolean;
period: "morning" | "afternoon";
};
/* -------------------------------- helpers --------------------------------- */
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
}
function addMonths(d: Date, n: number): Date {
return new Date(d.getFullYear(), d.getMonth() + n, 1);
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function clampDayAcrossMonths(d: Date, deltaMonths: number): Date {
const target = new Date(d.getFullYear(), d.getMonth() + deltaMonths, 1);
const dim = daysInMonth(target.getFullYear(), target.getMonth());
return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), dim));
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function isSameMonth(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}
function keyOf(d: Date): string {
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
}
function formatLong(d: Date): string {
return `${WEEKDAY_LONG[d.getDay()]}, ${MONTH_LONG[d.getMonth()]} ${d.getDate()}`;
}
function formatTime(minutes: number): string {
const h24 = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h24 >= 12 ? "PM" : "AM";
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${m.toString().padStart(2, "0")} ${period}`;
}
function dayStateFor(d: Date, today: Date): DayState {
if (startOfDay(d) < today) return "past";
const wd = d.getDay();
if (wd === 0 || wd === 6) return "weekend";
return "available";
}
function firstAvailableFrom(from: Date, today: Date): Date {
let d = startOfDay(from);
for (let i = 0; i < 21; i += 1) {
if (dayStateFor(d, today) === "available") return d;
d = addDays(d, 1);
}
return startOfDay(from);
}
// deterministic booked pattern so render is stable across SSR/CSR
function seededBooked(dateKey: string, minutes: number): boolean {
let h = 2166136261;
const s = `${dateKey}#${minutes}`;
for (let i = 0; i < s.length; i += 1) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 7 < 2; // ~29% booked
}
function buildSlots(date: Date, duration: number, nowMinutes: number | null, today: Date): Slot[] {
const dk = keyOf(date);
const isToday = isSameDay(date, today);
const slots: Slot[] = [];
const dayStart = 9 * 60;
const dayEnd = 17 * 60;
for (let m = dayStart; m + duration <= dayEnd; m += duration) {
const pastToday = isToday && nowMinutes !== null && m <= nowMinutes;
slots.push({
key: `${dk}-${m}`,
label: formatTime(m),
minutes: m,
booked: seededBooked(dk, m) || pastToday,
period: m < 12 * 60 ? "morning" : "afternoon",
});
}
return slots;
}
/* -------------------------------- icons ----------------------------------- */
function IconChevron({ className, dir }: { className?: string; dir: "left" | "right" }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d={dir === "left" ? "M15 18l-6-6 6-6" : "M9 6l6 6-6 6"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function IconClock({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.8" />
<path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconVideo({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<rect x="2.5" y="6" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.5 10l6-3v10l-6-3v-4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
}
function IconGlobe({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="M3 12h18M12 3c2.5 2.5 3.8 5.6 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.6-3.8-9S9.5 5.5 12 3z"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
function IconUser({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="12" cy="8" r="3.5" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 20c.7-3.6 3.5-5.5 7-5.5s6.3 1.9 7 5.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function IconMail({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 7.5l8 5 8-5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* ------------------------------- component -------------------------------- */
export default function CalendarBooking() {
const reduce = useReducedMotion();
const uid = useId();
const [today] = useState<Date>(() => startOfDay(new Date()));
const [selectedDate, setSelectedDate] = useState<Date>(() => firstAvailableFrom(today, today));
const [focusedDate, setFocusedDate] = useState<Date>(selectedDate);
const [viewMonth, setViewMonth] = useState<Date>(
() => new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1),
);
const [duration, setDuration] = useState<number>(30);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
const [timezone, setTimezone] = useState("");
const [nowMinutes, setNowMinutes] = useState<number | null>(null);
const dayRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
const durationRefs = useRef<(HTMLButtonElement | null)[]>([]);
const nameRef = useRef<HTMLInputElement | null>(null);
const emailRef = useRef<HTMLInputElement | null>(null);
const shouldFocusDay = useRef(false);
// AnimatePresence mode="wait" mounts the next panel only after the previous
// one exits, so panel focus is claimed on mount rather than in an effect.
const claimDoneFocus = useRef(false);
const claimPickFocus = useRef(false);
// resolve local timezone + current minute client-side (avoids hydration drift)
useEffect(() => {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz) setTimezone(tz.replace(/_/g, " "));
} catch {
setTimezone("");
}
const now = new Date();
setNowMinutes(now.getHours() * 60 + now.getMinutes());
}, []);
// move keyboard focus to the focused day after arrow navigation only
useEffect(() => {
if (!shouldFocusDay.current) return;
shouldFocusDay.current = false;
dayRefs.current.get(keyOf(focusedDate))?.focus();
}, [focusedDate]);
const minDate = useMemo(() => new Date(today.getFullYear(), today.getMonth(), 1), [today]);
const maxMonth = useMemo(() => addMonths(today, MAX_MONTHS_AHEAD), [today]);
const maxDate = useMemo(
() => new Date(maxMonth.getFullYear(), maxMonth.getMonth() + 1, 0),
[maxMonth],
);
const atMinMonth = isSameMonth(viewMonth, minDate);
const atMaxMonth = isSameMonth(viewMonth, maxMonth);
const weeks = useMemo(() => {
const y = viewMonth.getFullYear();
const m = viewMonth.getMonth();
const offset = new Date(y, m, 1).getDay();
const total = daysInMonth(y, m);
const cells: (Date | null)[] = [];
for (let i = 0; i < offset; i += 1) cells.push(null);
for (let d = 1; d <= total; d += 1) cells.push(new Date(y, m, d));
while (cells.length % 7 !== 0) cells.push(null);
const rows: (Date | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
return rows;
}, [viewMonth]);
const slots = useMemo(
() => buildSlots(selectedDate, duration, nowMinutes, today),
[selectedDate, duration, nowMinutes, today],
);
const morning = slots.filter((s) => s.period === "morning");
const afternoon = slots.filter((s) => s.period === "afternoon");
const openCount = slots.filter((s) => !s.booked).length;
const selectedSlotObj = slots.find((s) => s.key === selectedSlot) ?? null;
const monthKey = `${viewMonth.getFullYear()}-${viewMonth.getMonth()}`;
/* --------------------------- state transitions -------------------------- */
function resetForDateOrDuration() {
setSelectedSlot(null);
setConfirmed(false);
setErrors({});
}
function pickDate(d: Date) {
setSelectedDate(d);
setFocusedDate(d);
if (!isSameMonth(d, viewMonth)) setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
resetForDateOrDuration();
}
function pickDuration(v: number) {
setDuration(v);
resetForDateOrDuration();
}
function gotoMonth(delta: number) {
const nm = addMonths(viewMonth, delta);
if (nm < minDate || nm > maxMonth) return;
setViewMonth(nm);
setFocusedDate(isSameMonth(nm, today) ? today : new Date(nm.getFullYear(), nm.getMonth(), 1));
}
function onGridKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
let next: Date | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(focusedDate, -1);
break;
case "ArrowRight":
next = addDays(focusedDate, 1);
break;
case "ArrowUp":
next = addDays(focusedDate, -7);
break;
case "ArrowDown":
next = addDays(focusedDate, 7);
break;
case "Home":
next = addDays(focusedDate, -focusedDate.getDay());
break;
case "End":
next = addDays(focusedDate, 6 - focusedDate.getDay());
break;
case "PageUp":
next = clampDayAcrossMonths(focusedDate, -1);
break;
case "PageDown":
next = clampDayAcrossMonths(focusedDate, 1);
break;
default:
return;
}
if (next < minDate || next > maxDate) {
e.preventDefault();
return;
}
e.preventDefault();
shouldFocusDay.current = true;
setFocusedDate(next);
if (!isSameMonth(next, viewMonth)) {
setViewMonth(new Date(next.getFullYear(), next.getMonth(), 1));
}
}
function onDurationKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>, idx: number) {
let ni = idx;
if (e.key === "ArrowRight" || e.key === "ArrowDown") ni = (idx + 1) % DURATIONS.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
ni = (idx - 1 + DURATIONS.length) % DURATIONS.length;
else return;
e.preventDefault();
pickDuration(DURATIONS[ni].value);
durationRefs.current[ni]?.focus();
}
function onConfirm(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const next: { name?: string; email?: string } = {};
if (!name.trim()) next.name = "Please tell us who's joining.";
if (!EMAIL_RE.test(email.trim())) next.email = "Enter a valid email address.";
setErrors(next);
if (next.name) {
nameRef.current?.focus();
return;
}
if (next.email) {
emailRef.current?.focus();
return;
}
claimDoneFocus.current = true;
setConfirmed(true);
}
function bookAnother() {
claimPickFocus.current = true;
setConfirmed(false);
setSelectedSlot(null);
setName("");
setEmail("");
setErrors({});
}
const announce = confirmed
? `Booking confirmed for ${formatLong(selectedDate)} at ${selectedSlotObj?.label ?? ""}.`
: selectedSlotObj
? `${selectedSlotObj.label} selected on ${formatLong(selectedDate)}.`
: `${formatLong(selectedDate)} selected. Choose a time.`;
const panelTransition = reduce
? { duration: 0 }
: { duration: 0.28, ease: "easeOut" as const };
const gridMotion = reduce
? {}
: { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.22 } };
/* -------------------------------- render -------------------------------- */
return (
<section
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 dark:text-slate-100"
>
<style>{`
@keyframes cbk-fade-up { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: none; } }
@keyframes cbk-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.6); } }
@keyframes cbk-draw { to { stroke-dashoffset: 0; } }
@keyframes cbk-pop { 0% { transform: scale(0.6); opacity: 0; } 60% { transform: scale(1.06); } 100% { transform: scale(1); opacity: 1; } }
.cbk-slot { animation: cbk-fade-up 0.4s ease both; }
.cbk-dot { animation: cbk-pulse 2.4s ease-in-out infinite; }
.cbk-check-ring { animation: cbk-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.cbk-check-path { stroke-dasharray: 30; stroke-dashoffset: 30; animation: cbk-draw 0.45s ease 0.25s forwards; }
@media (prefers-reduced-motion: reduce) {
.cbk-slot, .cbk-dot, .cbk-check-ring, .cbk-check-path { animation: none !important; }
.cbk-check-path { stroke-dashoffset: 0; }
}
`}</style>
{/* ambient background accents */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-500/10" />
<div className="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-500/10" />
</div>
<span aria-live="polite" className="sr-only">
{announce}
</span>
<div className="relative mx-auto max-w-5xl">
<div className="mb-8 max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 backdrop-blur dark:border-indigo-500/30 dark:bg-slate-900/60 dark:text-indigo-300">
<span className="cbk-dot inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
Accepting bookings this week
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
Book a strategy call
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Pick a day and a time that suits you. We'll send a calendar invite with the
meeting link right after you confirm.
</p>
</div>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white/90 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30">
{/* host strip */}
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 px-5 py-4 sm:px-7 dark:border-slate-800">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-semibold text-white shadow-sm">
AR
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">Alex Rivera</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
Product strategy & onboarding
</p>
</div>
<div className="ms-auto flex flex-wrap items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5">
<IconClock className="h-4 w-4" />
{duration} min
</span>
<span className="inline-flex items-center gap-1.5">
<IconVideo className="h-4 w-4" />
Google Meet
</span>
</div>
</div>
<div className="grid gap-0 md:grid-cols-2">
{/* ------------------------- calendar ------------------------- */}
<div className="border-b border-slate-200 p-5 sm:p-7 md:border-b-0 md:border-r dark:border-slate-800">
<div className="mb-4 flex items-center justify-between">
<button
type="button"
onClick={() => gotoMonth(-1)}
disabled={atMinMonth}
aria-label="Previous month"
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
<IconChevron dir="left" className="h-4 w-4" />
</button>
<h3 aria-live="polite" className="text-sm font-semibold tracking-tight">
{MONTH_LONG[viewMonth.getMonth()]} {viewMonth.getFullYear()}
</h3>
<button
type="button"
onClick={() => gotoMonth(1)}
disabled={atMaxMonth}
aria-label="Next month"
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
<IconChevron dir="right" className="h-4 w-4" />
</button>
</div>
<div
role="grid"
aria-label={`${MONTH_LONG[viewMonth.getMonth()]} ${viewMonth.getFullYear()}`}
onKeyDown={onGridKeyDown}
>
<div role="row" className="grid grid-cols-7">
{WEEKDAY_SHORT.map((w, i) => (
<div
key={w}
role="columnheader"
aria-label={WEEKDAY_LONG[i]}
className="pb-2 text-center text-[0.7rem] font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500"
>
{w.slice(0, 2)}
</div>
))}
</div>
<motion.div key={monthKey} {...gridMotion} className="space-y-1">
{weeks.map((week, wi) => (
<div role="row" key={`${monthKey}-w${wi}`} className="grid grid-cols-7 gap-1">
{week.map((day, di) => {
if (!day) {
return <div key={`e-${monthKey}-${wi}-${di}`} role="gridcell" />;
}
const state = dayStateFor(day, today);
const selectable = state === "available";
const isSelected = isSameDay(day, selectedDate);
const isFocusTarget = isSameDay(day, focusedDate);
const isToday = isSameDay(day, today);
const base =
"relative flex h-10 w-full items-center justify-center rounded-lg text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900";
const look = isSelected
? "bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 hover:bg-indigo-600"
: selectable
? "text-slate-700 hover:bg-indigo-50 dark:text-slate-200 dark:hover:bg-slate-800"
: "cursor-not-allowed text-slate-300 line-through dark:text-slate-600";
return (
<div
role="gridcell"
key={keyOf(day)}
aria-selected={isSelected}
className="flex"
>
<button
type="button"
ref={(el) => {
dayRefs.current.set(keyOf(day), el);
}}
tabIndex={isFocusTarget ? 0 : -1}
aria-label={`${formatLong(day)}${
state === "past"
? ", unavailable"
: state === "weekend"
? ", no availability"
: ""
}`}
aria-disabled={!selectable}
aria-current={isToday ? "date" : undefined}
onClick={() => selectable && pickDate(day)}
className={`${base} ${look}`}
>
{day.getDate()}
{isToday && !isSelected && (
<span className="absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-indigo-500" />
)}
</button>
</div>
);
})}
</div>
))}
</motion.div>
</div>
<div className="mt-5 flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-2.5 text-xs text-slate-500 dark:bg-slate-800/60 dark:text-slate-400">
<IconGlobe className="h-4 w-4 shrink-0" />
<span className="truncate">
Times shown in {timezone || "your local time"}
</span>
</div>
</div>
{/* -------------------------- right panel -------------------- */}
<div className="p-5 sm:p-7">
<AnimatePresence mode="wait" initial={false}>
{confirmed ? (
<motion.div
key="done"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={panelTransition}
className="flex h-full flex-col items-center justify-center py-6 text-center"
>
<div className="cbk-check-ring flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15">
<svg viewBox="0 0 24 24" fill="none" className="h-8 w-8 text-emerald-600 dark:text-emerald-400" aria-hidden="true">
<path
className="cbk-check-path"
d="M5 12.5l4 4 10-10"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h3
ref={(el) => {
if (el && claimDoneFocus.current) {
claimDoneFocus.current = false;
el.focus();
}
}}
tabIndex={-1}
className="mt-5 text-xl font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900"
>
You're booked
</h3>
<p className="mt-1.5 max-w-xs text-sm text-slate-600 dark:text-slate-400">
A calendar invite is on its way to{" "}
<span className="font-medium text-slate-800 dark:text-slate-200">{email}</span>.
</p>
<dl className="mt-6 w-full max-w-xs space-y-2.5 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-left text-sm dark:border-slate-800 dark:bg-slate-800/50">
<div className="flex items-center gap-2.5">
<IconClock className="h-4 w-4 shrink-0 text-indigo-500" />
<dt className="sr-only">Date and time</dt>
<dd>
{formatLong(selectedDate)} · {selectedSlotObj?.label}
</dd>
</div>
<div className="flex items-center gap-2.5">
<IconVideo className="h-4 w-4 shrink-0 text-indigo-500" />
<dt className="sr-only">Meeting</dt>
<dd>{duration}-minute Google Meet with Alex Rivera</dd>
</div>
</dl>
<button
type="button"
onClick={bookAnother}
className="mt-6 rounded-xl px-4 py-2 text-sm font-medium text-indigo-600 underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
Book another time
</button>
</motion.div>
) : (
<motion.div
key="pick"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={panelTransition}
>
<div className="mb-4 flex items-baseline justify-between gap-3">
<div>
<h3
ref={(el) => {
if (el && claimPickFocus.current) {
claimPickFocus.current = false;
el.focus();
}
}}
tabIndex={-1}
className="text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900"
>
{WEEKDAY_LONG[selectedDate.getDay()]}
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{MONTH_LONG[selectedDate.getMonth()]} {selectedDate.getDate()}
</p>
</div>
<span
className={`rounded-full px-2.5 py-1 text-[0.7rem] font-medium ${
openCount === 0
? "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300"
: openCount <= 3
? "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
}`}
>
{openCount === 0
? "Fully booked"
: openCount <= 3
? `Only ${openCount} left`
: `${openCount} slots open`}
</span>
</div>
{/* duration segmented control */}
<div
role="radiogroup"
aria-label="Meeting length"
className="mb-5 inline-flex rounded-xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-700 dark:bg-slate-800/60"
>
{DURATIONS.map((d, idx) => {
const active = d.value === duration;
return (
<button
key={d.value}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
ref={(el) => {
durationRefs.current[idx] = el;
}}
onClick={() => pickDuration(d.value)}
onKeyDown={(e) => onDurationKeyDown(e, idx)}
className={`rounded-lg px-3.5 py-1.5 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-slate-900 ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{d.label}
</button>
);
})}
</div>
{/* slots */}
{openCount === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No times left on this day. Try another date on the calendar.
</div>
) : (
<div className="max-h-[19rem] space-y-5 overflow-y-auto pr-1">
{[
{ label: "Morning", list: morning },
{ label: "Afternoon", list: afternoon },
].map(
(group) =>
group.list.length > 0 && (
<div key={group.label}>
<p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{group.label}
</p>
<div
role="group"
aria-label={`${group.label} time slots`}
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
>
{group.list.map((slot, i) => {
const active = slot.key === selectedSlot;
return (
<button
key={slot.key}
type="button"
aria-pressed={active}
aria-disabled={slot.booked}
disabled={slot.booked}
onClick={() => setSelectedSlot(slot.key)}
style={reduce ? undefined : { animationDelay: `${i * 24}ms` }}
className={`cbk-slot rounded-xl border px-2 py-2.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900 ${
slot.booked
? "cursor-not-allowed border-slate-200 text-slate-300 line-through dark:border-slate-800 dark:text-slate-600"
: active
? "border-indigo-600 bg-indigo-600 text-white shadow-sm shadow-indigo-600/30"
: "border-slate-200 text-slate-700 hover:border-indigo-400 hover:bg-indigo-50 dark:border-slate-700 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:bg-slate-800"
}`}
>
{slot.label}
</button>
);
})}
</div>
</div>
),
)}
</div>
)}
{/* confirm form */}
<AnimatePresence initial={false}>
{selectedSlotObj && (
<motion.form
key="form"
initial={reduce ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={panelTransition}
onSubmit={onConfirm}
className="overflow-hidden"
>
<div className="mt-5 space-y-3 border-t border-slate-200 pt-5 dark:border-slate-800">
<p className="text-sm text-slate-600 dark:text-slate-400">
Confirm{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{formatLong(selectedDate)} at {selectedSlotObj.label}
</span>
</p>
<div>
<label
htmlFor={`${uid}-name`}
className="mb-1 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
Your name
</label>
<div className="relative">
<IconUser className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
id={`${uid}-name`}
ref={nameRef}
type="text"
value={name}
autoComplete="name"
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? `${uid}-name-err` : undefined}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
if (errors.name) setErrors((p) => ({ ...p, name: undefined }));
}}
placeholder="Jordan Lee"
className={`w-full rounded-xl border bg-white py-2.5 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 dark:bg-slate-950 dark:text-white dark:focus-visible:ring-offset-slate-900 ${
errors.name
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/60"
: "border-slate-200 focus-visible:ring-indigo-500 dark:border-slate-700"
}`}
/>
</div>
{errors.name && (
<p id={`${uid}-name-err`} className="mt-1 text-xs text-rose-600 dark:text-rose-400">
{errors.name}
</p>
)}
</div>
<div>
<label
htmlFor={`${uid}-email`}
className="mb-1 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
Email
</label>
<div className="relative">
<IconMail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
id={`${uid}-email`}
ref={emailRef}
type="email"
value={email}
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? `${uid}-email-err` : undefined}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
if (errors.email) setErrors((p) => ({ ...p, email: undefined }));
}}
placeholder="jordan@company.com"
className={`w-full rounded-xl border bg-white py-2.5 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 dark:bg-slate-950 dark:text-white dark:focus-visible:ring-offset-slate-900 ${
errors.email
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/60"
: "border-slate-200 focus-visible:ring-indigo-500 dark:border-slate-700"
}`}
/>
</div>
{errors.email && (
<p id={`${uid}-email-err`} className="mt-1 text-xs text-rose-600 dark:text-rose-400">
{errors.email}
</p>
)}
</div>
<button
type="submit"
className="mt-1 w-full rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white shadow-sm shadow-indigo-600/30 transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 active:scale-[0.99] dark:focus-visible:ring-offset-slate-900"
>
Confirm booking
</button>
</div>
</motion.form>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Calendar Date Picker
Originaldate picker with a dropdown calendar

Calendar Range Picker
Originaldate range picker with two months

Calendar Inline
Originalalways-visible inline calendar

Calendar Month Year
Originalmonth and year quick selector

Calendar Event
Originalmonth calendar with event dots

Calendar Mini
Originalcompact mini calendar widget

Calendar Time Picker
Originaltime picker with hour/minute

Calendar Datetime
Originalcombined date and time picker

Calendar Week
Originalweek view with time slots

Calendar Agenda
Originalagenda list of upcoming days

Calendar Availability
Originalavailability slot picker

Calendar Multi Month
Originaltwo-month scrolling calendar

