Web InnoventixFreeCode

Calendar Multi Month

Original · free

two-month scrolling calendar

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

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

type Range = { start: Date | null; end: Date | null };

const RATE = 189;
const MIN_NIGHTS = 2;
const MS_DAY = 86_400_000;
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
const WEEKDAY_FULL = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
] as const;

function atMidnight(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 firstOfMonth(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), 1);
}
function keyOf(d: Date): number {
  return atMidnight(d).getTime();
}
function monthNum(d: Date): number {
  return d.getFullYear() * 12 + d.getMonth();
}
function nightsBetween(a: Date, b: Date): number {
  return Math.round((keyOf(b) - keyOf(a)) / MS_DAY);
}
function startOfWeek(d: Date): Date {
  const dow = (d.getDay() + 6) % 7;
  return addDays(d, -dow);
}
function monthLabel(d: Date): string {
  return d.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
function fmtDay(d: Date): string {
  return d.toLocaleDateString(undefined, {
    weekday: "short",
    month: "short",
    day: "numeric",
  });
}
function cx(...parts: Array<string | false | null | undefined>): string {
  return parts.filter(Boolean).join(" ");
}

function monthCells(anchor: Date): Array<Date | null> {
  const y = anchor.getFullYear();
  const m = anchor.getMonth();
  const first = new Date(y, m, 1);
  const lead = (first.getDay() + 6) % 7;
  const total = new Date(y, m + 1, 0).getDate();
  const cells: Array<Date | null> = [];
  for (let i = 0; i < lead; i++) cells.push(null);
  for (let d = 1; d <= total; d++) cells.push(new Date(y, m, d));
  while (cells.length % 7 !== 0) cells.push(null);
  return cells;
}

export default function CalendarMultiMonth() {
  const reduce = useReducedMotion();

  const today = useMemo(() => atMidnight(new Date()), []);
  const currentNum = monthNum(today);

  const bookedSet = useMemo(() => {
    const set = new Set<number>();
    [4, 5, 6, 17, 18, 19, 20, 32, 33, 44, 45].forEach((n) =>
      set.add(keyOf(addDays(today, n)))
    );
    return set;
  }, [today]);

  const [viewMonth, setViewMonth] = useState<Date>(() => firstOfMonth(today));
  const [focusedDate, setFocusedDate] = useState<Date>(today);
  const [range, setRange] = useState<Range>({ start: null, end: null });
  const [hoverDate, setHoverDate] = useState<Date | null>(null);
  const [direction, setDirection] = useState<number>(1);
  const [confirmed, setConfirmed] = useState<boolean>(false);

  const dayRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
  const pendingFocus = useRef<boolean>(false);

  const viewNum = monthNum(viewMonth);

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

  function isBlocked(d: Date): boolean {
    return keyOf(d) < keyOf(today) || bookedSet.has(keyOf(d));
  }

  function spanHasBlocked(a: Date, b: Date): boolean {
    let cur = addDays(a, 1);
    while (keyOf(cur) <= keyOf(b)) {
      if (bookedSet.has(keyOf(cur))) return true;
      cur = addDays(cur, 1);
    }
    return false;
  }

  const effEnd = useMemo<Date | null>(() => {
    if (range.end) return range.end;
    if (
      range.start &&
      hoverDate &&
      keyOf(hoverDate) > keyOf(range.start) &&
      !spanHasBlocked(range.start, hoverDate)
    ) {
      return hoverDate;
    }
    return null;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [range.start, range.end, hoverDate]);

  const nights =
    range.start && range.end ? nightsBetween(range.start, range.end) : 0;
  const validStay = nights >= MIN_NIGHTS;
  const tooShort = nights > 0 && nights < MIN_NIGHTS;

  function pick(d: Date) {
    if (isBlocked(d)) return;
    setConfirmed(false);
    setHoverDate(null);
    if (!range.start || range.end) {
      setRange({ start: d, end: null });
      return;
    }
    if (keyOf(d) <= keyOf(range.start)) {
      setRange({ start: d, end: null });
      return;
    }
    if (spanHasBlocked(range.start, d)) {
      setRange({ start: d, end: null });
      return;
    }
    setRange({ start: range.start, end: d });
  }

  function clampView(nv: Date) {
    const fn = monthNum(focusedDate);
    const vn = monthNum(nv);
    if (fn < vn || fn > vn + 1) {
      setFocusedDate(vn === currentNum ? today : nv);
    }
  }

  function goPrev() {
    const nv = addMonths(viewMonth, -1);
    if (monthNum(nv) < currentNum) return;
    setDirection(-1);
    setViewMonth(nv);
    clampView(nv);
  }
  function goNext() {
    const nv = addMonths(viewMonth, 1);
    setDirection(1);
    setViewMonth(nv);
    clampView(nv);
  }

  function ensureVisible(next: Date) {
    const fn = monthNum(next);
    if (fn < viewNum) {
      setDirection(-1);
      setViewMonth(firstOfMonth(next));
    } else if (fn > viewNum + 1) {
      setDirection(1);
      setViewMonth(addMonths(firstOfMonth(next), -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 = startOfWeek(focusedDate);
        break;
      case "End":
        next = addDays(startOfWeek(focusedDate), 6);
        break;
      case "PageUp":
        next = new Date(
          focusedDate.getFullYear(),
          focusedDate.getMonth() - 1,
          focusedDate.getDate()
        );
        break;
      case "PageDown":
        next = new Date(
          focusedDate.getFullYear(),
          focusedDate.getMonth() + 1,
          focusedDate.getDate()
        );
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        pick(focusedDate);
        return;
      case "Escape":
        setRange({ start: null, end: null });
        setHoverDate(null);
        setConfirmed(false);
        return;
      default:
        return;
    }
    e.preventDefault();
    if (keyOf(next) < keyOf(today)) next = today;
    pendingFocus.current = true;
    setFocusedDate(next);
    ensureVisible(next);
    if (range.start && !range.end) setHoverDate(next);
  }

  function renderMonth(anchor: Date, labelId: string) {
    const cells = monthCells(anchor);
    return (
      <div className="flex-1">
        <div
          id={labelId}
          className="mb-3 text-center text-sm font-semibold tracking-wide text-zinc-900 dark:text-zinc-50"
        >
          {monthLabel(anchor)}
        </div>
        <div
          role="grid"
          aria-labelledby={labelId}
          onKeyDown={onGridKeyDown}
          className="select-none"
        >
          <div role="row" className="grid grid-cols-7">
            {WEEKDAYS.map((w, i) => (
              <div
                key={w}
                role="columnheader"
                aria-label={WEEKDAY_FULL[i]}
                className="pb-2 text-center text-[0.7rem] font-medium uppercase tracking-wider text-zinc-400 dark:text-zinc-500"
              >
                {w}
              </div>
            ))}
          </div>

          <div className="grid grid-cols-7 gap-y-0.5">
            {cells.map((d, i) => {
              if (!d) {
                return (
                  <div
                    key={`e-${i}`}
                    role="gridcell"
                    aria-hidden="true"
                    className="h-11"
                  />
                );
              }
              const t = keyOf(d);
              const blocked = isBlocked(d);
              const isToday = t === keyOf(today);
              const isStart = range.start && t === keyOf(range.start);
              const isEnd = effEnd && t === keyOf(effEnd);
              const isEndpoint = Boolean(isStart || isEnd);
              const inRange = Boolean(
                range.start &&
                  effEnd &&
                  t > keyOf(range.start) &&
                  t < keyOf(effEnd)
              );
              const hasSpan = Boolean(
                range.start && effEnd && keyOf(range.start) !== keyOf(effEnd)
              );
              const isFocused = t === keyOf(focusedDate);

              return (
                <div
                  key={t}
                  role="gridcell"
                  aria-selected={isEndpoint || inRange}
                  className="relative flex h-11 items-center justify-center"
                >
                  {(inRange || (hasSpan && isEndpoint)) && (
                    <span
                      aria-hidden="true"
                      className={cx(
                        "pointer-events-none absolute inset-y-1 bg-indigo-100 dark:bg-indigo-500/20",
                        inRange && "inset-x-0",
                        hasSpan && isStart && !isEnd && "left-1/2 right-0",
                        hasSpan && isEnd && !isStart && "left-0 right-1/2"
                      )}
                    />
                  )}

                  <button
                    type="button"
                    ref={(el) => {
                      if (el) dayRefs.current.set(t, el);
                      else dayRefs.current.delete(t);
                    }}
                    tabIndex={isFocused ? 0 : -1}
                    aria-disabled={blocked || undefined}
                    aria-current={isToday ? "date" : undefined}
                    aria-label={cx(
                      d.toLocaleDateString(undefined, {
                        weekday: "long",
                        month: "long",
                        day: "numeric",
                        year: "numeric",
                      }),
                      blocked && "unavailable",
                      Boolean(isStart) && "check-in",
                      Boolean(isEnd) && "check-out"
                    )}
                    onClick={() => pick(d)}
                    onMouseEnter={() => {
                      if (range.start && !range.end && !blocked)
                        setHoverDate(d);
                    }}
                    onFocus={() => {
                      if (range.start && !range.end && !blocked)
                        setHoverDate(d);
                    }}
                    className={cx(
                      "relative z-10 mx-auto flex h-10 w-10 items-center justify-center rounded-full text-sm transition-colors",
                      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950",
                      blocked &&
                        "cursor-not-allowed text-zinc-300 line-through decoration-1 dark:text-zinc-600",
                      !blocked &&
                        !isEndpoint &&
                        !inRange &&
                        "font-medium text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800",
                      !blocked &&
                        inRange &&
                        "font-medium text-indigo-900 dark:text-indigo-100",
                      isEndpoint &&
                        "bg-indigo-600 font-semibold text-white hover:bg-indigo-600 dark:bg-indigo-500"
                    )}
                  >
                    {d.getDate()}
                    {isToday && !isEndpoint && (
                      <span
                        aria-hidden="true"
                        className="cmm-dot pointer-events-none absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-emerald-500"
                      />
                    )}
                  </button>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    );
  }

  const liveText = range.start
    ? range.end
      ? `${fmtDay(range.start)} to ${fmtDay(range.end)} selected, ${nights} night${
          nights === 1 ? "" : "s"
        }.`
      : `Check-in ${fmtDay(range.start)} selected. Choose a check-out date.`
    : "No dates selected.";

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-4 py-16 sm:py-24 dark:bg-zinc-950">
      <style>{`
        @keyframes cmm-dot-pulse {
          0%, 100% { opacity: 1; transform: translateX(-50%) scale(1); }
          50% { opacity: 0.35; transform: translateX(-50%) scale(0.6); }
        }
        .cmm-dot { animation: cmm-dot-pulse 2.4s ease-in-out infinite; }
        @keyframes cmm-note-in {
          from { opacity: 0; transform: translateY(4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .cmm-note { animation: cmm-note-in 0.3s ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .cmm-dot, .cmm-note { animation: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
      />

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 16 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.45, ease: "easeOut" }}
        className="relative mx-auto w-full max-w-3xl overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-xl shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40"
      >
        <header className="flex items-start justify-between gap-4 border-b border-zinc-100 px-6 py-5 sm:px-8 dark:border-zinc-800">
          <div>
            <p className="text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              Cedar Ridge Cabin
            </p>
            <h2 className="mt-1 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
              Choose your stay
            </h2>
            <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
              Two-night minimum. Struck-through dates are already booked.
            </p>
          </div>

          <div className="flex shrink-0 items-center gap-1.5">
            <button
              type="button"
              onClick={goPrev}
              disabled={viewNum <= currentNum}
              aria-label="Previous month"
              className="flex h-9 w-9 items-center justify-center rounded-full border border-zinc-200 text-zinc-600 transition-colors hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M15 18l-6-6 6-6" />
              </svg>
            </button>
            <button
              type="button"
              onClick={goNext}
              aria-label="Next month"
              className="flex h-9 w-9 items-center justify-center rounded-full border border-zinc-200 text-zinc-600 transition-colors hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
            >
              <svg
                width="18"
                height="18"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M9 18l6-6-6-6" />
              </svg>
            </button>
          </div>
        </header>

        <div className="px-6 py-6 sm:px-8">
          <motion.div
            key={viewNum}
            custom={direction}
            initial={reduce ? false : { opacity: 0, x: direction * 24 }}
            animate={{ opacity: 1, x: 0 }}
            transition={{ duration: 0.28, ease: "easeOut" }}
            onMouseLeave={() => {
              if (!range.end) setHoverDate(null);
            }}
            className="flex flex-col gap-8 md:flex-row md:gap-10"
          >
            {renderMonth(viewMonth, "cmm-label-left")}
            <div
              aria-hidden="true"
              className="hidden w-px self-stretch bg-zinc-100 md:block dark:bg-zinc-800"
            />
            {renderMonth(addMonths(viewMonth, 1), "cmm-label-right")}
          </motion.div>

          <div
            role="status"
            aria-live="polite"
            className="sr-only"
          >
            {liveText}
          </div>

          <div className="mt-7 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-zinc-500 dark:text-zinc-400">
            <span className="inline-flex items-center gap-1.5">
              <span className="h-3 w-3 rounded-full bg-indigo-600 dark:bg-indigo-500" />
              Check-in / out
            </span>
            <span className="inline-flex items-center gap-1.5">
              <span className="h-3 w-5 rounded-sm bg-indigo-100 dark:bg-indigo-500/20" />
              In your stay
            </span>
            <span className="inline-flex items-center gap-1.5">
              <span className="relative h-3 w-3 rounded-full bg-emerald-500">
                <span className="absolute inset-0 rounded-full ring-1 ring-emerald-500" />
              </span>
              Today
            </span>
            <span className="inline-flex items-center gap-1.5 text-zinc-400 line-through decoration-1 dark:text-zinc-500">
              Unavailable
            </span>
          </div>
        </div>

        <footer className="border-t border-zinc-100 px-6 py-5 sm:px-8 dark:border-zinc-800">
          <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
            <dl className="grid grid-cols-2 gap-x-8 gap-y-1">
              <div>
                <dt className="text-[0.7rem] font-medium uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
                  Check-in
                </dt>
                <dd className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
                  {range.start ? fmtDay(range.start) : "Add date"}
                </dd>
              </div>
              <div>
                <dt className="text-[0.7rem] font-medium uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
                  Check-out
                </dt>
                <dd className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
                  {range.end ? fmtDay(range.end) : "Add date"}
                </dd>
              </div>
            </dl>

            <div className="flex items-center gap-3">
              <button
                type="button"
                onClick={() => {
                  setRange({ start: null, end: null });
                  setHoverDate(null);
                  setConfirmed(false);
                }}
                disabled={!range.start && !range.end}
                className="rounded-full px-3 py-2 text-sm font-medium text-zinc-500 underline-offset-4 transition-colors hover:text-zinc-900 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:no-underline dark:text-zinc-400 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-900"
              >
                Clear
              </button>
              <button
                type="button"
                onClick={() => setConfirmed(true)}
                disabled={!validStay}
                className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-700 dark:disabled:text-zinc-400 dark:focus-visible:ring-offset-zinc-900"
              >
                {validStay ? (
                  <>
                    Reserve
                    <span className="font-normal text-indigo-200">
                      ${(RATE * nights).toLocaleString()}
                    </span>
                  </>
                ) : (
                  "Reserve"
                )}
              </button>
            </div>
          </div>

          <p className="mt-3 min-h-[1.25rem] text-xs">
            {confirmed ? (
              <span className="cmm-note font-medium text-emerald-600 dark:text-emerald-400">
                Request sent — we&apos;ll email your confirmation for {nights}{" "}
                night{nights === 1 ? "" : "s"} at ${RATE.toLocaleString()}/night.
              </span>
            ) : tooShort ? (
              <span className="cmm-note font-medium text-rose-600 dark:text-rose-400">
                Minimum stay is {MIN_NIGHTS} nights — pick a later check-out.
              </span>
            ) : validStay ? (
              <span className="text-zinc-500 dark:text-zinc-400">
                {nights} night{nights === 1 ? "" : "s"} · $
                {RATE.toLocaleString()} × {nights} = $
                {(RATE * nights).toLocaleString()} before taxes.
              </span>
            ) : (
              <span className="text-zinc-400 dark:text-zinc-500">
                ${RATE.toLocaleString()} / night · Select your dates to see the
                total.
              </span>
            )}
          </p>
        </footer>
      </motion.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 →