Calendar Date Picker
Original · freedate picker with a dropdown calendar
byWeb InnoventixReact + Tailwind
calendardatepickercalendars
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-date-picker.jsoncalendar-date-picker.tsx
"use client";
import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const longFmt = new Intl.DateTimeFormat("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" });
const shortFmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" });
function startOfDay(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 {
const day = d.getDate();
const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
const last = new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate();
t.setDate(Math.min(day, last));
return t;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function isoDay(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export default function CalendarDatePicker() {
const reduce = useReducedMotion();
const labelId = useId();
const captionId = useId();
const dialogId = useId();
const [today] = useState<Date>(() => startOfDay(new Date()));
const [selected, setSelected] = useState<Date | null>(() => addDays(startOfDay(new Date()), 3));
const base = selected ?? today;
const [viewYear, setViewYear] = useState<number>(base.getFullYear());
const [viewMonth, setViewMonth] = useState<number>(base.getMonth());
const [focusedDate, setFocusedDate] = useState<Date>(base);
const [open, setOpen] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const gridRef = useRef<HTMLTableElement>(null);
const focusPending = useRef<boolean>(false);
function isDisabled(d: Date): boolean {
return startOfDay(d).getTime() < today.getTime();
}
// Move the roving focus to a new date, syncing the visible month.
function moveFocus(d: Date): void {
setFocusedDate(d);
if (d.getMonth() !== viewMonth || d.getFullYear() !== viewYear) {
setViewMonth(d.getMonth());
setViewYear(d.getFullYear());
}
focusPending.current = true;
}
// Prev/next arrows: shift the visible month and keep a valid roving target.
function changeMonth(delta: number): void {
const d = addMonths(focusedDate, delta);
setFocusedDate(d);
setViewMonth(d.getMonth());
setViewYear(d.getFullYear());
}
function openCalendar(): void {
const b = selected ?? today;
const d = new Date(b);
setFocusedDate(d);
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
focusPending.current = true;
setOpen(true);
}
function closeAndReturnFocus(): void {
setOpen(false);
triggerRef.current?.focus();
}
function selectDate(d: Date): void {
if (isDisabled(d)) return;
setSelected(startOfDay(d));
setOpen(false);
triggerRef.current?.focus();
}
function onGridKeyDown(e: KeyboardEvent<HTMLTableElement>): void {
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 = addDays(focusedDate, -focusedDate.getDay());
break;
case "End":
next = addDays(focusedDate, 6 - focusedDate.getDay());
break;
case "PageUp":
next = addMonths(focusedDate, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(focusedDate, e.shiftKey ? 12 : 1);
break;
case "Enter":
case " ":
e.preventDefault();
selectDate(focusedDate);
return;
case "Escape":
e.preventDefault();
closeAndReturnFocus();
return;
default:
return;
}
e.preventDefault();
moveFocus(next);
}
// Close on outside click.
useEffect(() => {
if (!open) return;
function onDown(e: globalThis.MouseEvent): void {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
// Roving focus: land on the active day whenever we intentionally moved it.
useEffect(() => {
if (!open || !focusPending.current) return;
const btn = gridRef.current?.querySelector<HTMLButtonElement>(
`button[data-day="${isoDay(focusedDate)}"]`,
);
btn?.focus();
focusPending.current = false;
}, [open, focusedDate]);
// Build a fixed 6-week grid for the visible month.
const firstOfMonth = new Date(viewYear, viewMonth, 1);
const gridStart = addDays(firstOfMonth, -firstOfMonth.getDay());
const weeks: Date[][] = [];
for (let w = 0; w < 6; w++) {
const row: Date[] = [];
for (let d = 0; d < 7; d++) row.push(addDays(gridStart, w * 7 + d));
weeks.push(row);
}
const daysFromNow =
selected != null
? Math.round((startOfDay(selected).getTime() - today.getTime()) / 86_400_000)
: null;
const relLabel =
daysFromNow == null
? "No date selected"
: daysFromNow === 0
? "Today"
: daysFromNow === 1
? "Tomorrow"
: daysFromNow < 0
? `${Math.abs(daysFromNow)} days ago`
: `In ${daysFromNow} days`;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-6 py-20 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 sm:px-8">
<style>{`
@keyframes cdp-pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.25); }
100% { transform: scale(1); opacity: 1; }
}
.cdp-dot { animation: cdp-pop 0.28s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.cdp-dot { animation: none !important; }
}
`}</style>
{/* Decorative backdrop grid */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.4] [background-image:linear-gradient(to_right,rgb(148_163_184/0.12)_1px,transparent_1px),linear-gradient(to_bottom,rgb(148_163_184/0.12)_1px,transparent_1px)] [background-size:44px_44px] dark:opacity-[0.25]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 right-[-10%] h-72 w-72 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/20"
/>
<div className="relative mx-auto grid max-w-4xl items-start gap-10 md:grid-cols-[minmax(0,1fr)_18rem]">
{/* Left: intro + picker */}
<div>
<p className="mb-3 inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Onboarding scheduler
</p>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50 sm:text-3xl">
Pick your kickoff date
</h2>
<p className="mt-2 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
A 45-minute walkthrough with the Innoventix team. Choose any upcoming day and we’ll
send the Zoom link plus a calendar invite within a minute.
</p>
<div ref={containerRef} className="relative mt-7 max-w-sm">
<label
id={labelId}
htmlFor={`${dialogId}-trigger`}
className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"
>
Session date
</label>
<button
ref={triggerRef}
id={`${dialogId}-trigger`}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-labelledby={labelId}
onClick={() => (open ? setOpen(false) : openCalendar())}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-300 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:border-indigo-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:hover:border-indigo-500 dark:focus-visible:ring-offset-slate-950"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 transition-colors group-hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:group-hover:bg-indigo-500/25">
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<rect x="3" y="4.5" width="18" height="16" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
<path d="M3 9h18M8 2.5v4M16 2.5v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{selected ? longFmt.format(selected) : "Select a date"}
</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">{relLabel}</span>
</span>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={`h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
key="cdp-popover"
role="dialog"
aria-label="Choose a session date"
aria-modal="false"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: reduce ? 0 : 0.18, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-0 top-full z-20 mt-2 w-[19.5rem] origin-top select-none rounded-2xl border border-slate-200 bg-white p-3 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
>
{/* Month navigation */}
<div className="mb-2 flex items-center justify-between px-1">
<button
type="button"
aria-label="Previous month"
onClick={() => changeMonth(-1)}
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:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
id={captionId}
aria-live="polite"
className="text-sm font-semibold text-slate-900 dark:text-slate-100"
>
{monthFmt.format(firstOfMonth)}
</div>
<button
type="button"
aria-label="Next month"
onClick={() => changeMonth(1)}
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:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<table
ref={gridRef}
role="grid"
aria-labelledby={captionId}
onKeyDown={onGridKeyDown}
className="w-full table-fixed border-collapse"
>
<thead>
<tr>
{WEEKDAYS.map((wd) => (
<th
key={wd}
scope="col"
abbr={wd}
className="pb-2 text-center text-[0.7rem] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500"
>
{wd.slice(0, 2)}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week) => (
<tr key={isoDay(week[0])} role="row">
{week.map((day) => {
const inMonth = day.getMonth() === viewMonth;
const isToday = isSameDay(day, today);
const isSelected = selected != null && isSameDay(day, selected);
const disabled = isDisabled(day);
const isFocusTarget = isSameDay(day, focusedDate);
const cls = [
"relative mx-auto flex h-9 w-9 items-center justify-center rounded-lg text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
];
if (isSelected) {
cls.push("bg-indigo-600 font-semibold text-white shadow-sm hover:bg-indigo-600");
} else if (disabled) {
cls.push("cursor-not-allowed text-slate-300 dark:text-slate-600");
} else if (!inMonth) {
cls.push("text-slate-300 hover:bg-slate-100 dark:text-slate-600 dark:hover:bg-slate-800");
} else {
cls.push("text-slate-700 hover:bg-indigo-50 hover:text-indigo-700 dark:text-slate-200 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-200");
}
if (isToday && !isSelected) {
cls.push("font-semibold text-indigo-600 ring-1 ring-inset ring-indigo-300 dark:text-indigo-300 dark:ring-indigo-500/50");
}
return (
<td key={isoDay(day)} className="p-0.5 text-center">
<button
type="button"
data-day={isoDay(day)}
tabIndex={isFocusTarget ? 0 : -1}
aria-selected={isSelected}
aria-disabled={disabled || undefined}
aria-current={isToday ? "date" : undefined}
aria-label={longFmt.format(day)}
onClick={() => selectDate(day)}
className={cls.join(" ")}
>
{day.getDate()}
{isSelected && (
<span className="cdp-dot absolute -bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-white" />
)}
{isToday && !isSelected && (
<span className="absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-indigo-500" />
)}
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
{/* Footer actions */}
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2.5 dark:border-slate-800">
<button
type="button"
onClick={() => moveFocus(new Date(today))}
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-300 dark:hover:bg-indigo-500/15"
>
Go to today
</button>
<button
type="button"
onClick={() => setSelected(null)}
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-rose-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-rose-400"
>
Clear
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
Use arrow keys to navigate, Enter to pick, Esc to close.
</p>
</div>
</div>
{/* Right: live summary */}
<aside className="rounded-2xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/60">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Your session
</h3>
{selected ? (
<div className="mt-3">
<p className="text-3xl font-semibold leading-none tracking-tight text-slate-900 dark:text-slate-50">
{selected.getDate()}
</p>
<p className="mt-1.5 text-sm font-medium text-slate-700 dark:text-slate-200">
{shortFmt.format(selected)}
</p>
<span className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{relLabel}
</span>
</div>
) : (
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">
No date chosen yet. Open the picker to reserve a slot.
</p>
)}
<dl className="mt-5 space-y-2.5 border-t border-slate-100 pt-4 text-sm dark:border-slate-800">
<div className="flex items-center justify-between gap-3">
<dt className="text-slate-500 dark:text-slate-400">Duration</dt>
<dd className="font-medium text-slate-800 dark:text-slate-200">45 min</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-slate-500 dark:text-slate-400">Format</dt>
<dd className="font-medium text-slate-800 dark:text-slate-200">Zoom call</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-slate-500 dark:text-slate-400">Host</dt>
<dd className="font-medium text-slate-800 dark:text-slate-200">Salman R.</dd>
</div>
</dl>
</aside>
</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 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

Calendar Multi Month
Originaltwo-month scrolling calendar

