Skip to content
Motion
FreeIn reviewv1.0.0

Booking Calendar

A date and time-slot picker with a keyboard grid; availability arrives as props, so any booking back end fits.

npx shadcn@latest add @vibebox/booking-calendar

Controls

Week starts
<BookingCalendar />

About

A calm way to pick an appointment. The month grid marks days that have open times with a small spectrum dot and strikes through the rest; choosing a day reveals its times as a grid of buttons, and a live line confirms "Wed, Oct 14 · 10:30 AM". The whole thing works from the keyboard as a proper grid, and screen readers hear each day's full date and how many times are open. Availability is a plain object of dates and times, so it works with any booking system: the calendar picks, your form or API books (VibeBox's booking module adds the data side later). Dates are handled as plain strings in UTC, so nothing shifts with the visitor's time zone. Original to VibeBox Motion.

Use it when

  • A clinic, salon, studio or service business takes appointments.
  • A booking call-to-action needs real slots instead of "message us to book".

Skip it when

  • Bookings need ranges (hotel stays); this picks one date and one time.
  • There is no real availability source yet; do not show invented slots.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/booking-calendar
  3. 3Use it:
    import { BookingCalendar } from "@/components/vibebox/booking-calendar";
    
    <BookingCalendar
      availability={slots}
      timeZoneLabel="Manila time (GMT+8)"
      onChange={(choice) => setForm((form) => ({ ...form, appointment: choice }))}
    />

Code

3 files
components/vibebox/booking-calendar/booking-calendar.client.tsx
/**
 * VibeBox Motion · Booking Calendar
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";
import { addDays, addMonths, formatDay, formatTime, monthGrid, monthOf, weekday, weekdayNames, type DayKey } from "./calendar";

/*
 * A date and time-slot picker. Availability arrives as props ({ "2026-10-14": ["09:00", ...] }),
 * so it works with any booking back end. The month is a keyboard grid (arrows, Home, End,
 * Page Up and Down); dates without times stay reachable but cannot be picked; times are native
 * radio buttons. It picks; your form or API books.
 */

const KEYFRAMES = `
@keyframes vb-bc-next { from { opacity: 0; transform: translateX(14px); } }
@keyframes vb-bc-prev { from { opacity: 0; transform: translateX(-14px); } }
@keyframes vb-bc-slot { from { opacity: 0; transform: translateY(6px); } }
@media (prefers-reduced-motion: no-preference) {
  [data-vb-bc-month="next"] { animation: vb-bc-next var(--vb-duration-section, 520ms) var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
  [data-vb-bc-month="prev"] { animation: vb-bc-prev var(--vb-duration-section, 520ms) var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
  [data-vb-bc-slot] { animation: vb-bc-slot var(--vb-duration-reveal, 320ms) var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) both; animation-delay: calc(var(--vb-bc-i) * 30ms); }
}
`;

export type BookingValue = { date: string; time: string };

export type BookingCalendarProps = {
  /** Open times by date: { "2026-10-14": ["09:00", "10:30"] }. Dates missing or empty are unavailable. */
  availability: Readonly<Record<string, readonly string[]>>;
  /** The month shown first ("2026-10"); defaults to the first month with availability. */
  month?: string;
  /** The picked date and time, when you control it. */
  value?: BookingValue | null;
  /** Called with the new choice, or null when the date changes and the time is cleared. */
  onChange?: (value: BookingValue | null) => void;
  /** Today, to mark it ("2026-10-12"). Leave it out to mark nothing. */
  today?: string;
  weekStartsOn?: "sunday" | "monday";
  /** BCP 47 locale for names and times. */
  locale?: string;
  /** 12-hour (10:30 AM) or 24-hour (10:30) times. */
  hour12?: boolean;
  /** Shown with the times, for example "Manila time (GMT+8)". */
  timeZoneLabel?: string;
  className?: string;
};

export function BookingCalendar({
  availability,
  month,
  value,
  onChange,
  today,
  weekStartsOn = "sunday",
  locale = "en-PH",
  hour12 = true,
  timeZoneLabel,
  className,
}: BookingCalendarProps) {
  const id = useId().replace(/[^a-zA-Z0-9-]/g, "");
  const start = weekStartsOn === "monday" ? 1 : 0;
  const days = useMemo(() => Object.keys(availability).filter((key) => availability[key]?.length).sort(), [availability]);
  const firstMonth = days[0] ? monthOf(days[0]) : undefined;
  const lastMonth = days.at(-1) ? monthOf(days.at(-1)!) : undefined;
  const [view, setView] = useState(month ?? firstMonth ?? monthOf(today ?? new Date().toISOString()));
  const [direction, setDirection] = useState<"next" | "prev" | "none">("none");
  const [own, setOwn] = useState<BookingValue | null>(null);
  const [draftDate, setDraftDate] = useState<DayKey | null>(null);
  const chosen = value === undefined ? own : value;
  const selectedDate = draftDate ?? chosen?.date ?? null;
  const [focus, setFocus] = useState<DayKey>(() => selectedDate ?? days.find((key) => monthOf(key) === view) ?? `${view}-01`);
  const moveFocus = useRef(false);

  // Keyboard moves land focus on the new day once it has rendered.
  useEffect(() => {
    if (!moveFocus.current) return;
    moveFocus.current = false;
    document.getElementById(`${id}-${focus}`)?.focus();
  }, [focus, id, view]);

  const goMonth = (delta: number) => {
    const next = addMonths(`${view}-01`, delta).slice(0, 7);
    setDirection(delta > 0 ? "next" : "prev");
    setView(next);
    setFocus(days.find((key) => monthOf(key) === next) ?? `${next}-01`);
  };

  const commit = (next: BookingValue | null) => {
    if (value === undefined) setOwn(next);
    onChange?.(next);
  };

  const pickDate = (key: DayKey) => {
    if (!availability[key]?.length) return;
    setFocus(key);
    if (chosen?.date === key) return;
    setDraftDate(key);
    if (chosen) commit(null);
  };

  const pickTime = (time: string) => {
    if (!selectedDate) return;
    setDraftDate(null);
    commit({ date: selectedDate, time });
  };

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, key: DayKey) => {
    const moves: Record<string, () => DayKey> = {
      ArrowLeft: () => addDays(key, -1),
      ArrowRight: () => addDays(key, 1),
      ArrowUp: () => addDays(key, -7),
      ArrowDown: () => addDays(key, 7),
      Home: () => addDays(key, -((weekday(key) - start + 7) % 7)),
      End: () => addDays(key, 6 - ((weekday(key) - start + 7) % 7)),
      PageUp: () => addMonths(key, -1),
      PageDown: () => addMonths(key, 1),
    };
    const move = moves[event.key];
    if (!move) return;
    event.preventDefault();
    let next = move();
    // Stay within the months that have availability.
    if (firstMonth && monthOf(next) < firstMonth) next = `${firstMonth}-01`;
    if (lastMonth && monthOf(next) > lastMonth) next = addDays(addMonths(`${lastMonth}-01`, 1), -1);
    if (monthOf(next) !== view) {
      setDirection(monthOf(next) > view ? "next" : "prev");
      setView(monthOf(next));
    }
    moveFocus.current = true;
    setFocus(next);
  };

  const weeks = monthGrid(view, start);
  const shortNames = weekdayNames(locale, start, "short");
  const longNames = weekdayNames(locale, start, "long");
  const times = selectedDate ? [...(availability[selectedDate] ?? [])].sort() : [];
  const title = formatDay(`${view}-01`, locale, { month: "long", year: "numeric" });
  const label = (key: DayKey, options: Intl.DateTimeFormatOptions = { weekday: "short", month: "short", day: "numeric" }) => formatDay(key, locale, options);

  return (
    <div data-vb="booking-calendar" className={cn("grid gap-4 md:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]", className)}>
      <style href="vb-booking-calendar" precedence="vb">
        {KEYFRAMES}
      </style>
      <div className="rounded-xl border border-border bg-card p-4 text-card-foreground sm:p-5">
        <div className="mb-3 flex items-center justify-between gap-2">
          <h2 id={`${id}-title`} aria-live="polite" className="font-display text-body font-semibold">
            {title}
          </h2>
          <div className="flex gap-1">
            {(
              [
                [-1, "Previous month", "m15 6-6 6 6 6", !firstMonth || view <= firstMonth],
                [1, "Next month", "m9 6 6 6-6 6", !lastMonth || view >= lastMonth],
              ] as const
            ).map(([delta, name, path, disabled]) => (
              <button
                key={name}
                type="button"
                aria-label={name}
                disabled={disabled}
                onClick={() => goMonth(delta)}
                className="grid size-9 place-items-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-30"
              >
                <svg viewBox="0 0 24 24" aria-hidden="true" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                  <path d={path} />
                </svg>
              </button>
            ))}
          </div>
        </div>
        <table role="grid" aria-labelledby={`${id}-title`} className="w-full table-fixed border-collapse">
          <thead>
            <tr>
              {shortNames.map((name, index) => (
                <th key={name} scope="col" abbr={longNames[index]} className="pb-2 text-center text-caption font-medium text-subtle-foreground">
                  {name}
                </th>
              ))}
            </tr>
          </thead>
          <tbody key={view} data-vb-bc-month={direction}>
            {weeks.map((week, row) => (
              <tr key={row}>
                {week.map((key, column) => {
                  if (!key) return <td key={column} />;
                  const open = Boolean(availability[key]?.length);
                  const selected = key === selectedDate;
                  return (
                    <td key={key} aria-selected={selected} className="p-0.5 text-center">
                      <button
                        id={`${id}-${key}`}
                        type="button"
                        tabIndex={key === focus ? 0 : -1}
                        aria-disabled={!open || undefined}
                        aria-current={key === today ? "date" : undefined}
                        aria-label={`${label(key, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}, ${open ? `${availability[key]!.length} times open` : "no times"}`}
                        onClick={() => pickDate(key)}
                        onKeyDown={(event) => {
                          if (event.key === "Enter" || event.key === " ") return;
                          onKeyDown(event, key);
                        }}
                        onFocus={() => setFocus(key)}
                        className={cn(
                          "relative mx-auto grid aspect-square w-full max-w-11 place-items-center rounded-lg text-small tabular-nums transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
                          selected
                            ? "bg-primary font-semibold text-primary-foreground"
                            : open
                              ? "font-medium text-foreground hover:bg-accent"
                              : "cursor-default text-subtle-foreground/60 line-through decoration-subtle-foreground/40",
                        )}
                      >
                        {Number(key.slice(8))}
                        {open && !selected && <span aria-hidden="true" className="absolute bottom-1.5 size-1 rounded-full bg-spectrum" />}
                        {key === today && <span aria-hidden="true" className="absolute inset-1 rounded-md border border-border-strong" />}
                      </button>
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      <div className="flex flex-col rounded-xl border border-border bg-card p-4 text-card-foreground sm:p-5">
        {selectedDate ? (
          <fieldset key={selectedDate} className="flex flex-col gap-3">
            <legend className="mb-3 font-display text-body font-semibold">Times on {label(selectedDate)}</legend>
            <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3">
              {times.map((time, index) => {
                const checked = chosen?.date === selectedDate && chosen.time === time;
                return (
                  <label
                    key={time}
                    data-vb-bc-slot=""
                    style={{ "--vb-bc-i": index } as CSSProperties}
                    className={cn(
                      "relative flex h-11 cursor-pointer items-center justify-center rounded-lg border text-small font-medium tabular-nums transition-colors duration-150 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring",
                      checked ? "border-primary bg-primary text-primary-foreground" : "border-border hover:border-border-strong",
                    )}
                  >
                    <input type="radio" name={`${id}-time`} value={time} checked={checked} onChange={() => pickTime(time)} className="sr-only" />
                    {formatTime(time, locale, hour12)}
                  </label>
                );
              })}
            </div>
            {timeZoneLabel && <p className="text-caption text-subtle-foreground">{timeZoneLabel}</p>}
          </fieldset>
        ) : (
          <p className="my-auto text-center text-small text-muted-foreground">Pick a date to see open times.</p>
        )}
        <p aria-live="polite" className="mt-auto border-t border-border pt-3 text-small">
          {chosen ? (
            <>
              <span className="text-muted-foreground">Selected: </span>
              <span className="font-medium">
                {label(chosen.date)} · {formatTime(chosen.time, locale, hour12)}
              </span>
            </>
          ) : (
            <span className="text-subtle-foreground">No time picked yet</span>
          )}
        </p>
      </div>
    </div>
  );
}
components/vibebox/booking-calendar/calendar.ts
/**
 * VibeBox Motion · Booking Calendar
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */

/*
 * Calendar maths on plain "YYYY-MM-DD" strings, done in UTC so a date never shifts with the
 * visitor's time zone, and the server and the browser always agree.
 */

export type DayKey = string;

export function parseDay(key: DayKey) {
  const [year, month, day] = key.split("-").map(Number) as [number, number, number];
  return { year, month, day };
}

export function dayKey(year: number, month: number, day: number): DayKey {
  return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}

const utc = (key: DayKey) => {
  const { year, month, day } = parseDay(key);
  return Date.UTC(year, month - 1, day);
};

const fromUtc = (time: number): DayKey => {
  const date = new Date(time);
  return dayKey(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
};

export const addDays = (key: DayKey, days: number) => fromUtc(utc(key) + days * 86_400_000);

export const daysInMonth = (year: number, month: number) => new Date(Date.UTC(year, month, 0)).getUTCDate();

/** 0 is Sunday. */
export const weekday = (key: DayKey) => new Date(utc(key)).getUTCDay();

/** The same day number in a month `delta` months away, clamped to that month's length. */
export function addMonths(key: DayKey, delta: number): DayKey {
  const { year, month, day } = parseDay(key);
  const index = year * 12 + (month - 1) + delta;
  const nextYear = Math.floor(index / 12);
  const nextMonth = (index % 12) + 1;
  return dayKey(nextYear, nextMonth, Math.min(day, daysInMonth(nextYear, nextMonth)));
}

export const monthOf = (key: DayKey) => key.slice(0, 7);

/** The month as weeks of seven cells (null for days outside it). */
export function monthGrid(month: string, weekStartsOn: 0 | 1): (DayKey | null)[][] {
  const [year, monthNumber] = month.split("-").map(Number) as [number, number];
  const first = dayKey(year, monthNumber, 1);
  const lead = (weekday(first) - weekStartsOn + 7) % 7;
  const cells: (DayKey | null)[] = Array.from({ length: lead }, () => null);
  for (let day = 1; day <= daysInMonth(year, monthNumber); day++) cells.push(dayKey(year, monthNumber, day));
  while (cells.length % 7) cells.push(null);
  return Array.from({ length: cells.length / 7 }, (_, week) => cells.slice(week * 7, week * 7 + 7));
}

export function formatDay(key: DayKey, locale: string, options: Intl.DateTimeFormatOptions) {
  return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }).format(utc(key));
}

export function formatTime(time: string, locale: string, hour12: boolean) {
  const [hour, minute] = time.split(":").map(Number) as [number, number];
  return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", hour12, timeZone: "UTC" }).format(Date.UTC(2000, 0, 1, hour, minute));
}

/** Weekday names, starting on the chosen day. */
export function weekdayNames(locale: string, weekStartsOn: 0 | 1, style: "short" | "long") {
  // 2026-01-04 was a Sunday.
  return Array.from({ length: 7 }, (_, index) => formatDay(addDays("2026-01-04", index + weekStartsOn), locale, { weekday: style }));
}
components/vibebox/booking-calendar/index.tsx
/**
 * VibeBox Motion · Booking Calendar
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { BookingCalendar, type BookingCalendarProps, type BookingValue } from "./booking-calendar.client";

Props

PropTypeDefaultDescription
availability *Record<string, readonly string[]>—Open times by date ({ "2026-10-14": ["09:00", "10:30"] }); missing or empty dates are unavailable.
monthstring—The month shown first ("2026-10"); defaults to the first month with availability.
value{ date: string; time: string } | null—The picked date and time, when you control it.
onChange(value: BookingValue | null) => void—Called with the new choice, or null when the date changes and the time clears.
todaystring—Today ("2026-10-12"), to mark it.
weekStartsOn"sunday" | "monday""sunday"The first column of the grid.
localestring"en-PH"BCP 47 locale for names and times.
hour12booleantrue12-hour (10:30 AM) or 24-hour (10:30) times.
timeZoneLabelstring—Shown with the times, for example "Manila time (GMT+8)".
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
One day is in the tab order; arrows move by day and week, Home and End to the week's ends, Page Up and Down by month, Enter or Space picks. Times are radio buttons.
Screen readers
A labelled grid; each day reads its full date and how many times are open; the chosen time is announced in a live summary.
Touch
Days and times are at least 44 px; months change with the arrow buttons.
Reduced motion
Months and times change in place, without sliding or rising.

Edge cases and limits

  • Times are wall-clock strings in the business's time zone; convert before passing them if your source stores UTC.
  • Months without any availability are skipped by the arrows; pass month to open elsewhere.
  • A Contact us button opening into call, Viber, Messenger and email links

    One button that blooms into Call, Text, Viber, Messenger, WhatsApp and email: the channels customers already use.

    Forms and booking · CSS

↑ ↓ to move↵ to openesc to close