Web InnoventixFreeCode

Calendar Availability

Original · free

availability slot picker

byWeb InnoventixReact + Tailwind
calendaravailabilitycalendars
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-availability.json
calendar-availability.tsx
"use client";

import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Duration = 30 | 60;

type Cell = {
  date: Date;
  key: string;
  inMonth: boolean;
  isPast: boolean;
  isToday: boolean;
  weekend: boolean;
  count: number;
};

type DisplaySlot = {
  start: string;
  startLabel: string;
  endLabel: string;
};

type Booking = {
  dateLabel: string;
  startLabel: string;
  endLabel: string;
  duration: Duration;
  timezoneLabel: string;
};

const TIMES = [
  "09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
  "13:00", "13:30", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30",
] as const;

const MONTHS_LONG = [
  "January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December",
];
const WEEKDAYS_LONG = [
  "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
];
const WEEKDAY_MIN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

const TIMEZONES = [
  { value: "pt", label: "Pacific Time · GMT-7" },
  { value: "et", label: "Eastern Time · GMT-4" },
  { value: "gmt", label: "London · GMT+1" },
  { value: "pkt", label: "Karachi · GMT+5" },
  { value: "sgt", label: "Singapore · GMT+8" },
];

function keyOf(d: Date): string {
  return `${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 {
  const base = new Date(d.getFullYear(), d.getMonth() + n, 1);
  const last = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate();
  base.setDate(Math.min(d.getDate(), last));
  return base;
}
function startOfWeekMonday(d: Date): Date {
  const g = (d.getDay() + 6) % 7;
  return addDays(d, -g);
}
function toMinutes(t: string): number {
  const [h, m] = t.split(":").map(Number);
  return h * 60 + m;
}
function label12(mins: number): string {
  const h24 = Math.floor(mins / 60);
  const m = mins % 60;
  const ampm = h24 < 12 ? "AM" : "PM";
  let h = h24 % 12;
  if (h === 0) h = 12;
  return `${h}:${m.toString().padStart(2, "0")} ${ampm}`;
}
function isWeekend(d: Date): boolean {
  const g = d.getDay();
  return g === 0 || g === 6;
}
function slotAvailability(d: Date): boolean[] {
  const n = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
  return TIMES.map((_, i) => {
    const h = (n * 9301 + i * 49297 + i * i * 769) >>> 0;
    return h % 100 >= 42;
  });
}
function longDate(d: Date): string {
  return `${WEEKDAYS_LONG[d.getDay()]}, ${MONTHS_LONG[d.getMonth()]} ${d.getDate()}`;
}

export default function CalendarAvailability() {
  const reduce = useReducedMotion();
  const monthLabelId = useId();
  const gridLabelId = useId();

  const [today] = useState<Date>(() => {
    const n = new Date();
    return new Date(n.getFullYear(), n.getMonth(), n.getDate());
  });

  const [viewYear, setViewYear] = useState<number>(today.getFullYear());
  const [viewMonth, setViewMonth] = useState<number>(today.getMonth());
  const [focusedDate, setFocusedDate] = useState<Date>(today);
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [duration, setDuration] = useState<Duration>(30);
  const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
  const [timezone, setTimezone] = useState<string>(TIMEZONES[0].value);
  const [booking, setBooking] = useState<Booking | null>(null);

  const dayRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
  const shouldFocusRef = useRef<boolean>(false);

  useEffect(() => {
    if (!shouldFocusRef.current) return;
    const el = dayRefs.current.get(keyOf(focusedDate));
    if (el) el.focus();
    shouldFocusRef.current = false;
  }, [focusedDate]);

  const cells = useMemo<Cell[]>(() => {
    const start = startOfWeekMonday(new Date(viewYear, viewMonth, 1));
    const list: Cell[] = [];
    for (let i = 0; i < 42; i++) {
      const date = addDays(start, i);
      const inMonth = date.getMonth() === viewMonth;
      const isPast = date.getTime() < today.getTime();
      const weekend = isWeekend(date);
      const count = isPast || weekend ? 0 : slotAvailability(date).filter(Boolean).length;
      list.push({
        date,
        key: keyOf(date),
        inMonth,
        isPast,
        isToday: keyOf(date) === keyOf(today),
        weekend,
        count,
      });
    }
    return list;
  }, [viewYear, viewMonth, today]);

  const displaySlots = useMemo<DisplaySlot[]>(() => {
    if (!selectedDate) return [];
    const avail = slotAvailability(selectedDate);
    const out: DisplaySlot[] = [];
    for (let i = 0; i < TIMES.length; i++) {
      if (!avail[i]) continue;
      const startMin = toMinutes(TIMES[i]);
      if (duration === 30) {
        out.push({ start: TIMES[i], startLabel: label12(startMin), endLabel: label12(startMin + 30) });
      } else {
        const next = i + 1;
        if (next < TIMES.length && avail[next] && toMinutes(TIMES[next]) - startMin === 30) {
          out.push({ start: TIMES[i], startLabel: label12(startMin), endLabel: label12(startMin + 60) });
        }
      }
    }
    return out;
  }, [selectedDate, duration]);

  const focusedKey = keyOf(focusedDate);
  const selectedKey = selectedDate ? keyOf(selectedDate) : null;
  const timezoneLabel = TIMEZONES.find((t) => t.value === timezone)?.label ?? "";

  const canPrev =
    viewYear > today.getFullYear() ||
    (viewYear === today.getFullYear() && viewMonth > today.getMonth());

  function isSelectable(c: Cell): boolean {
    return c.inMonth && !c.isPast && !c.weekend && c.count > 0;
  }

  function goMonth(delta: number) {
    if (delta < 0 && !canPrev) return;
    const base = new Date(viewYear, viewMonth + delta, 1);
    setViewYear(base.getFullYear());
    setViewMonth(base.getMonth());
    setFocusedDate(base);
  }

  function moveFocus(target: Date) {
    if (target.getFullYear() !== viewYear || target.getMonth() !== viewMonth) {
      setViewYear(target.getFullYear());
      setViewMonth(target.getMonth());
    }
    shouldFocusRef.current = true;
    setFocusedDate(target);
  }

  function onDayKeyDown(e: KeyboardEvent<HTMLButtonElement>, date: Date) {
    let handled = true;
    switch (e.key) {
      case "ArrowRight": moveFocus(addDays(date, 1)); break;
      case "ArrowLeft": moveFocus(addDays(date, -1)); break;
      case "ArrowDown": moveFocus(addDays(date, 7)); break;
      case "ArrowUp": moveFocus(addDays(date, -7)); break;
      case "Home": moveFocus(startOfWeekMonday(date)); break;
      case "End": moveFocus(addDays(startOfWeekMonday(date), 6)); break;
      case "PageUp": moveFocus(addMonths(date, -1)); break;
      case "PageDown": moveFocus(addMonths(date, 1)); break;
      default: handled = false;
    }
    if (handled) e.preventDefault();
  }

  function selectDay(c: Cell) {
    if (!isSelectable(c)) return;
    setSelectedDate(c.date);
    setSelectedSlot(null);
    setBooking(null);
    setFocusedDate(c.date);
  }

  function changeDuration(d: Duration) {
    setDuration(d);
    setSelectedSlot(null);
    setBooking(null);
  }

  function confirmBooking() {
    if (!selectedDate || !selectedSlot) return;
    const slot = displaySlots.find((s) => s.start === selectedSlot);
    if (!slot) return;
    setBooking({
      dateLabel: longDate(selectedDate),
      startLabel: slot.startLabel,
      endLabel: slot.endLabel,
      duration,
      timezoneLabel,
    });
  }

  const selectedSlotLabel = selectedSlot
    ? displaySlots.find((s) => s.start === selectedSlot)?.startLabel ?? null
    : null;

  const announce = booking
    ? `Confirmed: ${booking.dateLabel} at ${booking.startLabel}, ${booking.timezoneLabel}.`
    : selectedDate
      ? selectedSlotLabel
        ? `${longDate(selectedDate)} at ${selectedSlotLabel} selected. Ready to confirm.`
        : `${longDate(selectedDate)} selected. ${displaySlots.length} ${duration}-minute openings.`
      : "Select a date to view available times.";

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes cavail-rise {
          from { opacity: 0; transform: translateY(8px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes cavail-pop {
          0% { transform: scale(.85); opacity: 0; }
          60% { transform: scale(1.04); }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes cavail-draw {
          from { stroke-dashoffset: 26; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes cavail-ping {
          0% { transform: scale(1); opacity: .55; }
          70%, 100% { transform: scale(2.4); opacity: 0; }
        }
        .cavail-slot { animation: cavail-rise .32s cubic-bezier(.22,.8,.28,1) both; }
        .cavail-check-path {
          stroke-dasharray: 26;
          stroke-dashoffset: 26;
          animation: cavail-draw .5s .12s cubic-bezier(.65,0,.35,1) forwards;
        }
        .cavail-ring { animation: cavail-ping 1.9s cubic-bezier(0,0,.2,1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cavail-slot, .cavail-check-path, .cavail-ring { animation: none !important; }
          .cavail-check-path { stroke-dashoffset: 0; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-[90vw] -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-300/40 via-violet-300/25 to-sky-200/30 blur-3xl dark:from-indigo-600/25 dark:via-violet-700/15 dark:to-sky-800/15"
      />

      <div className="relative mx-auto w-full max-w-5xl">
        <div className="overflow-hidden rounded-3xl border border-slate-200/80 bg-white shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40 dark:ring-white/5">
          {/* header */}
          <div className="flex flex-col gap-5 border-b border-slate-200/80 p-6 sm:p-8 dark:border-slate-800">
            <div className="flex items-start gap-4">
              <span
                aria-hidden="true"
                className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-base font-semibold tracking-tight text-white shadow-lg shadow-indigo-500/25"
              >
                AR
              </span>
              <div className="min-w-0">
                <div className="flex items-center gap-2">
                  <span className="relative flex h-2 w-2">
                    <span className="cavail-ring absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                    <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
                  </span>
                  <span className="text-xs font-medium uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
                    Booking open
                  </span>
                </div>
                <h2 className="mt-1 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white">
                  {duration}-minute intro call with Ava Reyes
                </h2>
                <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
                  Product strategy consult · Video via Meet · No prep needed
                </p>
              </div>
            </div>

            {/* controls */}
            <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
              <fieldset className="min-w-0">
                <legend className="mb-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
                  Meeting length
                </legend>
                <div className="inline-flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800">
                  {([30, 60] as Duration[]).map((d) => (
                    <label key={d} className="relative cursor-pointer">
                      <input
                        type="radio"
                        name="cavail-duration"
                        value={d}
                        checked={duration === d}
                        onChange={() => changeDuration(d)}
                        className="peer sr-only"
                      />
                      <span className="block rounded-lg px-4 py-1.5 text-sm font-medium text-slate-600 transition-colors peer-checked:bg-white peer-checked:text-indigo-700 peer-checked:shadow-sm peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-500 dark:text-slate-300 dark:peer-checked:bg-slate-950 dark:peer-checked:text-indigo-300">
                        {d} min
                      </span>
                    </label>
                  ))}
                </div>
              </fieldset>

              <div className="min-w-0">
                <label
                  htmlFor="cavail-tz"
                  className="mb-1.5 block text-xs font-medium text-slate-500 dark:text-slate-400"
                >
                  Time zone
                </label>
                <div className="relative">
                  <select
                    id="cavail-tz"
                    value={timezone}
                    onChange={(e) => setTimezone(e.target.value)}
                    className="w-full appearance-none rounded-xl border border-slate-200 bg-white py-2 pl-3 pr-9 text-sm font-medium text-slate-700 transition-colors hover:border-slate-300 focus-visible:border-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 sm:w-64 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:hover:border-slate-600"
                  >
                    {TIMEZONES.map((tz) => (
                      <option key={tz.value} value={tz.value}>
                        {tz.label}
                      </option>
                    ))}
                  </select>
                  <svg
                    aria-hidden="true"
                    viewBox="0 0 24 24"
                    className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <path d="m6 9 6 6 6-6" />
                  </svg>
                </div>
              </div>
            </div>
          </div>

          {/* body */}
          <div className="grid gap-0 md:grid-cols-2">
            {/* calendar */}
            <div className="border-b border-slate-200/80 p-6 sm:p-8 md:border-b-0 md:border-r dark:border-slate-800">
              <div className="mb-4 flex items-center justify-between">
                <h3 id={monthLabelId} className="text-base font-semibold tracking-tight text-slate-900 dark:text-white">
                  {MONTHS_LONG[viewMonth]} {viewYear}
                </h3>
                <div className="flex gap-1">
                  <button
                    type="button"
                    onClick={() => goMonth(-1)}
                    disabled={!canPrev}
                    aria-label="Previous month"
                    className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
                  >
                    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                      <path d="m15 18-6-6 6-6" />
                    </svg>
                  </button>
                  <button
                    type="button"
                    onClick={() => goMonth(1)}
                    aria-label="Next month"
                    className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
                  >
                    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                      <path d="m9 18 6-6-6-6" />
                    </svg>
                  </button>
                </div>
              </div>

              <div className="grid grid-cols-7 gap-1">
                {WEEKDAY_MIN.map((w) => (
                  <div
                    key={w}
                    className="pb-2 text-center text-xs font-medium text-slate-400 dark:text-slate-500"
                    aria-hidden="true"
                  >
                    {w}
                  </div>
                ))}
              </div>

              <div
                role="grid"
                aria-labelledby={`${monthLabelId} ${gridLabelId}`}
                className="grid grid-cols-7 gap-1"
              >
                <span id={gridLabelId} className="sr-only">
                  Choose an available date
                </span>
                {cells.map((c) => {
                  if (!c.inMonth) {
                    return (
                      <div key={c.key} role="gridcell" aria-hidden="true" className="aspect-square" />
                    );
                  }
                  const selectable = isSelectable(c);
                  const isSelected = c.key === selectedKey;
                  const tabbable = c.key === focusedKey;
                  return (
                    <div key={c.key} role="gridcell" className="relative">
                      <button
                        ref={(el) => {
                          dayRefs.current.set(c.key, el);
                        }}
                        type="button"
                        tabIndex={tabbable ? 0 : -1}
                        aria-selected={isSelected}
                        aria-disabled={!selectable}
                        aria-label={`${longDate(c.date)}, ${
                          selectable
                            ? `${c.count} openings`
                            : c.weekend
                              ? "closed"
                              : c.isPast
                                ? "past"
                                : "no openings"
                        }`}
                        onClick={() => selectDay(c)}
                        onKeyDown={(e) => onDayKeyDown(e, c.date)}
                        className={[
                          "flex aspect-square w-full flex-col items-center justify-center rounded-xl text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500",
                          isSelected
                            ? "bg-indigo-600 text-white shadow-md shadow-indigo-500/30 dark:bg-indigo-500"
                            : selectable
                              ? "text-slate-700 hover:bg-indigo-50 dark:text-slate-200 dark:hover:bg-indigo-500/10"
                              : "cursor-not-allowed text-slate-300 dark:text-slate-600",
                          !isSelected && c.isToday ? "ring-1 ring-inset ring-indigo-400 dark:ring-indigo-500" : "",
                        ].join(" ")}
                      >
                        <span>{c.date.getDate()}</span>
                        <span
                          aria-hidden="true"
                          className={[
                            "mt-0.5 h-1 w-1 rounded-full",
                            isSelected
                              ? "bg-white/80"
                              : selectable
                                ? "bg-emerald-500"
                                : "bg-transparent",
                          ].join(" ")}
                        />
                      </button>
                    </div>
                  );
                })}
              </div>

              <div className="mt-4 flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
                <span className="flex items-center gap-1.5">
                  <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
                  Available
                </span>
                <span className="flex items-center gap-1.5">
                  <span className="h-3 w-3 rounded-full ring-1 ring-indigo-400 dark:ring-indigo-500" aria-hidden="true" />
                  Today
                </span>
              </div>
            </div>

            {/* slots / confirmation */}
            <div className="p-6 sm:p-8">
              <AnimatePresence mode="wait" initial={false}>
                {booking ? (
                  <motion.div
                    key="confirmed"
                    initial={reduce ? false : { opacity: 0, scale: 0.97 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98 }}
                    transition={{ duration: reduce ? 0 : 0.3, ease: [0.22, 0.8, 0.28, 1] }}
                    className="flex h-full flex-col"
                  >
                    <div className="flex flex-1 flex-col items-center justify-center py-6 text-center">
                      <span
                        style={reduce ? undefined : { animation: "cavail-pop .4s cubic-bezier(.22,.8,.28,1) both" }}
                        className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400"
                      >
                        <svg viewBox="0 0 24 24" className="h-8 w-8" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <path className="cavail-check-path" d="m5 13 4 4L19 7" />
                        </svg>
                      </span>
                      <h3 className="mt-5 text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
                        You&rsquo;re booked
                      </h3>
                      <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                        A calendar invite is on its way to your inbox.
                      </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-950/50">
                        <div className="flex justify-between gap-4">
                          <dt className="text-slate-500 dark:text-slate-400">Date</dt>
                          <dd className="font-medium text-slate-900 dark:text-white">{booking.dateLabel}</dd>
                        </div>
                        <div className="flex justify-between gap-4">
                          <dt className="text-slate-500 dark:text-slate-400">Time</dt>
                          <dd className="font-medium text-slate-900 dark:text-white">
                            {booking.startLabel} – {booking.endLabel}
                          </dd>
                        </div>
                        <div className="flex justify-between gap-4">
                          <dt className="text-slate-500 dark:text-slate-400">Zone</dt>
                          <dd className="font-medium text-slate-900 dark:text-white">{booking.timezoneLabel}</dd>
                        </div>
                      </dl>
                    </div>
                    <button
                      type="button"
                      onClick={() => setBooking(null)}
                      className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
                    >
                      Change time
                    </button>
                  </motion.div>
                ) : (
                  <motion.div
                    key="slots"
                    initial={reduce ? false : { opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    transition={{ duration: reduce ? 0 : 0.2 }}
                    className="flex h-full flex-col"
                  >
                    <div className="mb-4">
                      <h3 className="text-base font-semibold tracking-tight text-slate-900 dark:text-white">
                        {selectedDate ? longDate(selectedDate) : "Pick a date"}
                      </h3>
                      <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
                        {selectedDate
                          ? `${displaySlots.length} ${duration}-minute ${
                              displaySlots.length === 1 ? "opening" : "openings"
                            } · ${timezoneLabel}`
                          : "Available times appear here once you choose a day."}
                      </p>
                    </div>

                    {!selectedDate ? (
                      <div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 py-14 text-center dark:border-slate-800">
                        <svg viewBox="0 0 24 24" className="h-10 w-10 text-slate-300 dark:text-slate-600" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <rect x="3" y="4" width="18" height="18" rx="2" />
                          <path d="M16 2v4M8 2v4M3 10h18" />
                        </svg>
                        <p className="mt-3 max-w-[16rem] text-sm text-slate-400 dark:text-slate-500">
                          Select an available date from the calendar to see open times.
                        </p>
                      </div>
                    ) : displaySlots.length === 0 ? (
                      <div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 py-14 text-center dark:border-slate-800">
                        <p className="text-sm font-medium text-slate-500 dark:text-slate-400">
                          No {duration}-minute openings on this day.
                        </p>
                        <p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
                          Try a 30-minute call or another date.
                        </p>
                      </div>
                    ) : (
                      <div
                        role="listbox"
                        aria-label={`Available times on ${longDate(selectedDate)}`}
                        className="grid max-h-72 grid-cols-2 gap-2 overflow-y-auto pr-1 sm:grid-cols-3"
                      >
                        {displaySlots.map((slot, i) => {
                          const active = slot.start === selectedSlot;
                          return (
                            <button
                              key={slot.start}
                              type="button"
                              role="option"
                              aria-selected={active}
                              aria-label={`${slot.startLabel} to ${slot.endLabel}`}
                              onClick={() => setSelectedSlot(active ? null : slot.start)}
                              style={reduce ? undefined : { animationDelay: `${i * 26}ms` }}
                              className={[
                                "cavail-slot rounded-xl border px-2 py-2.5 text-center text-sm font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500",
                                active
                                  ? "border-indigo-600 bg-indigo-600 text-white shadow-sm shadow-indigo-500/30 dark:border-indigo-500 dark:bg-indigo-500"
                                  : "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-indigo-500/10",
                              ].join(" ")}
                            >
                              {slot.startLabel}
                            </button>
                          );
                        })}
                      </div>
                    )}

                    <button
                      type="button"
                      onClick={confirmBooking}
                      disabled={!selectedSlot}
                      className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-500/25 transition-colors hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:disabled:bg-slate-800 dark:disabled:text-slate-600"
                    >
                      {selectedSlotLabel ? (
                        <>
                          Confirm {selectedSlotLabel}
                          <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                            <path d="M5 12h14M13 6l6 6-6 6" />
                          </svg>
                        </>
                      ) : (
                        "Select a time"
                      )}
                    </button>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
          Times shown in {timezoneLabel}. Reschedule anytime from your confirmation email.
        </p>

        <div aria-live="polite" role="status" className="sr-only">
          {announce}
        </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 →