Calendar Multi Month
Original · freetwo-month scrolling calendar
byWeb InnoventixReact + Tailwind
calendarmultimonthcalendars
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.jsoncalendar-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'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 quoteSimilar components
Browse all →
Calendar Date Picker
Originaldate picker with a dropdown calendar

Calendar Range Picker
Originaldate range picker with two months

Calendar Inline
Originalalways-visible inline calendar

Calendar Month Year
Originalmonth and year quick selector

Calendar Event
Originalmonth calendar with event dots

Calendar Mini
Originalcompact mini calendar widget

Calendar Time Picker
Originaltime picker with hour/minute

Calendar Datetime
Originalcombined date and time picker

Calendar Week
Originalweek view with time slots

Calendar Agenda
Originalagenda list of upcoming days

Calendar Availability
Originalavailability slot picker

Calendar Booking
Originalbooking calendar with time slots

