Calendar Range Picker
Original · freedate range picker with two months
byWeb InnoventixReact + Tailwind
calendarrangepickercalendars
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-range-picker.jsoncalendar-range-picker.tsx
"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
/* ------------------------------------------------------------------ */
/* date helpers */
/* ------------------------------------------------------------------ */
const MS_DAY = 86_400_000;
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
const fmtFull = new Intl.DateTimeFormat("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
const fmtChip = new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
const fmtMonth = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
});
type Cell = { date: Date; inMonth: boolean };
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
}
function addDays(d: Date, n: number): Date {
const nd = new Date(d);
nd.setDate(nd.getDate() + n);
return nd;
}
function addMonths(d: Date, n: number): Date {
const nd = new Date(d.getFullYear(), d.getMonth() + n, 1);
const last = new Date(nd.getFullYear(), nd.getMonth() + 1, 0).getDate();
nd.setDate(Math.min(d.getDate(), last));
return nd;
}
function same(a: Date | null, b: Date | null): boolean {
return (
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function isBefore(a: Date, b: Date): boolean {
return startOfDay(a).getTime() < startOfDay(b).getTime();
}
function within(d: Date, lo: Date, hi: Date): boolean {
const t = startOfDay(d).getTime();
return t >= startOfDay(lo).getTime() && t <= startOfDay(hi).getTime();
}
function nightsBetween(a: Date, b: Date): number {
return Math.round(
(startOfDay(b).getTime() - startOfDay(a).getTime()) / MS_DAY,
);
}
function isoKey(d: Date): string {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function buildWeeks(view: Date): Cell[][] {
const first = startOfMonth(view);
const gridStart = addDays(first, -first.getDay());
const weeks: Cell[][] = [];
for (let w = 0; w < 6; w++) {
const row: Cell[] = [];
for (let d = 0; d < 7; d++) {
const date = addDays(gridStart, w * 7 + d);
row.push({ date, inMonth: date.getMonth() === view.getMonth() });
}
weeks.push(row);
}
return weeks;
}
function cx(...c: Array<string | false | null | undefined>): string {
return c.filter(Boolean).join(" ");
}
/* ------------------------------------------------------------------ */
/* icons */
/* ------------------------------------------------------------------ */
function ChevronLeft() {
return (
<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>
);
}
function ChevronRight() {
return (
<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>
);
}
function CalendarIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
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>
);
}
function ArrowIcon() {
return (
<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="M5 12h14M13 6l6 6-6 6" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* component */
/* ------------------------------------------------------------------ */
type Preset = { id: string; label: string; get: () => [Date, Date] };
export default function CalendarRangePicker() {
const reduce = useReducedMotion();
const [today] = useState<Date>(() => startOfDay(new Date()));
const [viewMonth, setViewMonth] = useState<Date>(() => startOfMonth(today));
const [start, setStart] = useState<Date | null>(null);
const [end, setEnd] = useState<Date | null>(null);
const [hover, setHover] = useState<Date | null>(null);
const [focused, setFocused] = useState<Date>(today);
const [applied, setApplied] = useState<{ start: Date; end: Date } | null>(
null,
);
const dayRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
const navigating = useRef(false);
useEffect(() => {
if (!navigating.current) return;
navigating.current = false;
dayRefs.current.get(isoKey(focused))?.focus();
}, [focused, viewMonth]);
const presets = useMemo<Preset[]>(() => {
const sat = addDays(today, (6 - today.getDay() + 7) % 7);
const sun = addDays(sat, 1);
return [
{ id: "weekend", label: "This weekend", get: () => [sat, sun] },
{
id: "next-weekend",
label: "Next weekend",
get: () => [addDays(sat, 7), addDays(sun, 7)],
},
{ id: "week", label: "1 week", get: () => [today, addDays(today, 7)] },
{
id: "two-weeks",
label: "2 weeks",
get: () => [today, addDays(today, 14)],
},
{ id: "month", label: "1 month", get: () => [today, addMonths(today, 1)] },
];
}, [today]);
const activePreset = useMemo(() => {
if (!start || !end) return null;
return (
presets.find((p) => {
const [lo, hi] = p.get();
return same(lo, start) && same(hi, end);
})?.id ?? null
);
}, [presets, start, end]);
const previewRange = useMemo(() => {
if (start && end) return { lo: start, hi: end };
if (start && !end && hover && !isBefore(hover, start)) {
return { lo: start, hi: hover };
}
return null;
}, [start, end, hover]);
const nights = start && end ? nightsBetween(start, end) : 0;
const choosingEnd = Boolean(start && !end);
/* --------------------------- actions ---------------------------- */
function moveFocus(next: Date) {
let target = next;
if (isBefore(target, today)) target = today;
if (same(target, focused)) return;
navigating.current = true;
const lo = startOfMonth(viewMonth);
const hi = endOfMonth(addMonths(viewMonth, 1));
if (isBefore(target, lo)) setViewMonth(startOfMonth(target));
else if (startOfDay(target).getTime() > startOfDay(hi).getTime())
setViewMonth(startOfMonth(addMonths(target, -1)));
setFocused(target);
}
function selectDate(date: Date) {
if (isBefore(date, today)) return;
setApplied(null);
setFocused(date);
if (!start || (start && end)) {
setStart(date);
setEnd(null);
setHover(null);
return;
}
if (same(date, start)) return;
if (isBefore(date, start)) {
setStart(date);
} else {
setEnd(date);
setHover(null);
}
}
function clampFocus(view: Date): Date {
const lo = startOfMonth(view);
const hi = endOfMonth(addMonths(view, 1));
let f = focused;
if (isBefore(f, lo)) f = lo;
else if (startOfDay(f).getTime() > startOfDay(hi).getTime()) f = hi;
if (isBefore(f, today)) f = today;
return f;
}
function shiftView(delta: number) {
const next = addMonths(viewMonth, delta);
setViewMonth(next);
setFocused(clampFocus(next));
}
function applyPreset(p: Preset) {
const [lo, hi] = p.get();
setStart(lo);
setEnd(hi);
setHover(null);
setApplied(null);
setFocused(lo);
setViewMonth(startOfMonth(lo));
}
function clearAll() {
setStart(null);
setEnd(null);
setHover(null);
setApplied(null);
}
function onDayKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>, date: Date) {
let handled = true;
switch (e.key) {
case "ArrowLeft":
moveFocus(addDays(date, -1));
break;
case "ArrowRight":
moveFocus(addDays(date, 1));
break;
case "ArrowUp":
moveFocus(addDays(date, -7));
break;
case "ArrowDown":
moveFocus(addDays(date, 7));
break;
case "Home":
moveFocus(addDays(date, -date.getDay()));
break;
case "End":
moveFocus(addDays(date, 6 - date.getDay()));
break;
case "PageUp":
moveFocus(addMonths(date, -1));
break;
case "PageDown":
moveFocus(addMonths(date, 1));
break;
case "Enter":
case " ":
selectDate(date);
break;
default:
handled = false;
}
if (handled) e.preventDefault();
}
const prevDisabled =
startOfDay(viewMonth).getTime() <= startOfMonth(today).getTime();
const liveMessage = applied
? `Dates confirmed from ${fmtChip.format(applied.start)} to ${fmtChip.format(
applied.end,
)}.`
: start && end
? `Selected ${fmtChip.format(start)} to ${fmtChip.format(end)}, ${nights} ${
nights === 1 ? "night" : "nights"
}.`
: start
? `Check-in ${fmtChip.format(start)} selected, now choose a check-out date.`
: "No dates selected.";
/* ---------------------------- render ---------------------------- */
function renderMonth(base: Date, offset: number) {
const weeks = buildWeeks(base);
const label = fmtMonth.format(base);
const headingId = `crp-month-${offset}`;
return (
<div className="min-w-0 flex-1">
<div className="mb-3 flex items-center justify-between">
{offset === 0 ? (
<button
type="button"
onClick={() => shiftView(-1)}
disabled={prevDisabled}
aria-label="Previous month"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-30 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
<ChevronLeft />
</button>
) : (
<span className="h-8 w-8" aria-hidden="true" />
)}
<h3
id={headingId}
className="text-sm font-semibold text-slate-900 dark:text-white"
>
{label}
</h3>
{offset === 1 ? (
<button
type="button"
onClick={() => shiftView(1)}
aria-label="Next month"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
<ChevronRight />
</button>
) : (
<span className="h-8 w-8" aria-hidden="true" />
)}
</div>
<div role="grid" aria-labelledby={headingId} className="w-full">
<div role="row" className="grid grid-cols-7">
{WEEKDAYS.map((w) => (
<div
key={w}
role="columnheader"
aria-label={w}
className="flex h-8 items-center justify-center text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500"
>
{w}
</div>
))}
</div>
{weeks.map((week, wi) => (
<div role="row" key={wi} className="grid grid-cols-7">
{week.map((cell) => {
if (!cell.inMonth) {
return (
<div
role="gridcell"
key={isoKey(cell.date)}
className="h-10"
/>
);
}
const date = cell.date;
const disabled = isBefore(date, today);
const isToday = same(date, today);
const isStart = same(date, start);
const isEnd = same(date, end);
const inR =
previewRange !== null &&
within(date, previewRange.lo, previewRange.hi);
const isLo = previewRange !== null && same(date, previewRange.lo);
const isHi = previewRange !== null && same(date, previewRange.hi);
const solid = isStart || isEnd || isLo || isHi;
// range strip (behind the day button)
let strip: string | null = null;
if (inR && !(isLo && isHi)) {
const rowStart = date.getDay() === 0;
const rowEnd = date.getDay() === 6;
let pos = "inset-x-0";
if (isLo) pos = "left-1/2 right-0";
else if (isHi) pos = "left-0 right-1/2";
let rounded = "";
if (rowStart && !isLo) rounded += " rounded-l-full";
if (rowEnd && !isHi) rounded += " rounded-r-full";
strip = cx(
"pointer-events-none absolute inset-y-1 bg-indigo-100 dark:bg-indigo-500/25",
pos,
rounded,
);
}
return (
<div
role="gridcell"
aria-selected={solid || inR}
key={isoKey(date)}
className="relative flex h-10 items-center justify-center"
>
{strip && <span className={strip} aria-hidden="true" />}
{disabled ? (
<span className="flex h-9 w-9 items-center justify-center rounded-full text-sm text-slate-300 dark:text-slate-600">
{date.getDate()}
</span>
) : (
<button
type="button"
ref={(el) => {
dayRefs.current.set(isoKey(date), el);
}}
tabIndex={same(date, focused) ? 0 : -1}
aria-label={cx(
fmtFull.format(date),
isStart && "(check-in)",
isEnd && "(check-out)",
isToday && "(today)",
)}
aria-current={isToday ? "date" : undefined}
onClick={() => selectDate(date)}
onKeyDown={(e) => onDayKeyDown(e, date)}
onMouseEnter={() => {
if (choosingEnd) setHover(date);
}}
onFocus={() => {
if (choosingEnd) setHover(date);
}}
className={cx(
"relative z-10 flex h-9 w-9 select-none items-center justify-center rounded-full text-sm tabular-nums 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-slate-900",
solid
? "bg-indigo-600 font-semibold text-white hover:bg-indigo-600"
: inR
? "text-slate-900 hover:bg-indigo-200/70 dark:text-slate-100 dark:hover:bg-indigo-500/30"
: "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
isToday &&
!solid &&
"ring-1 ring-inset ring-indigo-400/70",
)}
>
{date.getDate()}
</button>
)}
</div>
);
})}
</div>
))}
</div>
</div>
);
}
return (
<section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20">
<style>{`
@keyframes crp-pop {
0% { transform: scale(0.7); opacity: 0; }
60% { transform: scale(1.08); }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.crp-anim { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-4xl">
<header className="mb-8">
<span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium uppercase tracking-widest text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<CalendarIcon />
Availability
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
When are you traveling?
</h2>
<p className="mt-2 max-w-xl text-base text-slate-600 dark:text-slate-400">
Pick your check-in and check-out across both months. Use the arrow
keys to move day by day, Enter to select.
</p>
</header>
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30"
>
{/* selection summary bar */}
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-slate-50/80 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/60">
<DateChip
caption="Check-in"
value={start ? fmtChip.format(start) : null}
active={!choosingEnd}
/>
<ArrowIcon />
<DateChip
caption="Check-out"
value={end ? fmtChip.format(end) : null}
active={choosingEnd}
/>
{start && end && (
<span
key={`${isoKey(start)}-${isoKey(end)}`}
className="crp-anim ml-auto inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
style={reduce ? undefined : { animation: "crp-pop 0.3s ease" }}
>
{nights} {nights === 1 ? "night" : "nights"}
</span>
)}
</div>
<div className="flex flex-col gap-6 p-5 lg:flex-row lg:p-6">
{/* presets */}
<div className="flex shrink-0 flex-wrap gap-2 lg:w-40 lg:flex-col">
<span className="hidden text-xs font-semibold uppercase tracking-wider text-slate-400 lg:block dark:text-slate-500">
Quick ranges
</span>
{presets.map((p) => {
const active = activePreset === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => applyPreset(p)}
aria-pressed={active}
className={cx(
"rounded-lg border px-3 py-1.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500",
active
? "border-indigo-500 bg-indigo-50 font-medium text-indigo-700 dark:border-indigo-400/60 dark:bg-indigo-500/15 dark:text-indigo-200"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
)}
>
{p.label}
</button>
);
})}
</div>
{/* two months */}
<div
className="grid flex-1 grid-cols-1 gap-8 sm:grid-cols-2"
onMouseLeave={() => setHover(null)}
>
{renderMonth(viewMonth, 0)}
{renderMonth(addMonths(viewMonth, 1), 1)}
</div>
</div>
{/* footer */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 bg-slate-50/80 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/60">
<p className="text-sm text-slate-600 dark:text-slate-400" aria-hidden="true">
{applied ? (
<span className="font-medium text-emerald-700 dark:text-emerald-300">
Held {fmtChip.format(applied.start)} → {fmtChip.format(applied.end)}. We'll email your confirmation.
</span>
) : start && end ? (
<span>
{fmtChip.format(start)} → {fmtChip.format(end)}
</span>
) : (
"Select a check-in date to begin."
)}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={clearAll}
disabled={!start && !end}
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
>
Clear
</button>
<button
type="button"
onClick={() =>
start && end && setApplied({ start, end })
}
disabled={!(start && end)}
className="rounded-lg bg-indigo-600 px-4 py-2 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:opacity-40 dark:focus-visible:ring-offset-slate-900"
>
Reserve dates
</button>
</div>
</div>
</motion.div>
<p role="status" aria-live="polite" className="sr-only">
{liveMessage}
</p>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* sub-view */
/* ------------------------------------------------------------------ */
function DateChip({
caption,
value,
active,
}: {
caption: string;
value: string | null;
active: boolean;
}) {
return (
<div
className={cx(
"flex min-w-[8.5rem] flex-col rounded-xl border px-3 py-2 transition-colors",
active
? "border-indigo-400 bg-indigo-50/60 dark:border-indigo-400/50 dark:bg-indigo-500/10"
: "border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900",
)}
>
<span className="text-[0.65rem] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{caption}
</span>
<span
className={cx(
"text-sm font-semibold",
value
? "text-slate-900 dark:text-white"
: "text-slate-400 dark:text-slate-500",
)}
>
{value ?? "Add date"}
</span>
</div>
);
}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 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

Calendar Multi Month
Originaltwo-month scrolling calendar

