Web InnoventixFreeCode

Calendar Booking

Original · free

booking calendar with time slots

byWeb InnoventixReact + Tailwind
calendarbookingcalendars
Open

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.json
calendar-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&apos;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 &amp; 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&apos;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)} &middot; {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 quote

Similar components

Browse all →