Calendar Compact
Original · freetiny inline date field calendar
byWeb InnoventixReact + Tailwind
calendarcompactcalendars
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-compact.jsoncalendar-compact.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/* ---------------------------------------------------------------- helpers */
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const MONTHS_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const WEEKDAYS_FULL = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const WEEKDAYS_SHORT = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function addDays(d: Date, n: number): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function addMonthsClamped(d: Date, n: number): Date {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const dim = daysInMonth(target.getFullYear(), target.getMonth());
target.setDate(Math.min(d.getDate(), dim));
return target;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** Monday-first weekday index: Mon = 0 ... Sun = 6 */
function isoWeekday(d: Date): number {
return (d.getDay() + 6) % 7;
}
function formatDisplay(d: Date): string {
return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
function formatLong(d: Date): string {
return `${WEEKDAYS_FULL[isoWeekday(d)]}, ${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
function makeDate(y: number, m: number, day: number): Date | null {
const dt = new Date(y, m, day);
if (dt.getFullYear() === y && dt.getMonth() === m && dt.getDate() === day) {
return dt;
}
return null;
}
function parseDate(input: string): Date | null {
const s = input.trim();
if (!s) return null;
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s);
if (iso) return makeDate(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
const mdy = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(s);
if (mdy) return makeDate(Number(mdy[3]), Number(mdy[1]) - 1, Number(mdy[2]));
const fallback = new Date(s);
if (!Number.isNaN(fallback.getTime())) {
return new Date(
fallback.getFullYear(),
fallback.getMonth(),
fallback.getDate(),
);
}
return null;
}
function buildGrid(viewMonth: Date): Date[] {
const monthStart = startOfMonth(viewMonth);
const gridStart = addDays(monthStart, -isoWeekday(monthStart));
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
}
/* -------------------------------------------------------------- component */
type CalendarCompactProps = {
/** Optional initial value as ISO (yyyy-mm-dd), m/d/yyyy, or any parseable date. */
defaultValue?: string;
};
export default function CalendarCompact({ defaultValue }: CalendarCompactProps) {
const reduce = useReducedMotion();
const [today] = useState(() => {
const n = new Date();
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
});
const [selected, setSelected] = useState<Date | null>(() =>
defaultValue ? parseDate(defaultValue) : null,
);
const [inputValue, setInputValue] = useState<string>(() =>
selected ? formatDisplay(selected) : "",
);
const [open, setOpen] = useState(false);
const [viewMonth, setViewMonth] = useState<Date>(() =>
startOfMonth(selected ?? today),
);
const [focusedDate, setFocusedDate] = useState<Date>(() => selected ?? today);
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const gridRef = useRef<HTMLDivElement | null>(null);
const wantGridFocus = useRef(false);
const dialogId = useId();
const captionId = useId();
const labelId = useId();
const helpId = useId();
/* focus the roving day when navigating with the keyboard */
useEffect(() => {
if (!open || !wantGridFocus.current) return;
wantGridFocus.current = false;
const el = gridRef.current?.querySelector<HTMLButtonElement>(
'[data-focused="true"]',
);
el?.focus();
}, [open, focusedDate, viewMonth]);
/* close on outside pointer */
useEffect(() => {
if (!open) return;
function onDown(e: PointerEvent) {
const node = e.target as Node | null;
if (node && rootRef.current && !rootRef.current.contains(node)) {
setOpen(false);
}
}
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open]);
function openCalendar() {
const base = selected ?? today;
setFocusedDate(base);
setViewMonth(startOfMonth(base));
wantGridFocus.current = true;
setOpen(true);
}
function closeCalendar(returnFocus: boolean) {
setOpen(false);
if (returnFocus) {
requestAnimationFrame(() => triggerRef.current?.focus());
}
}
function moveFocus(next: Date) {
wantGridFocus.current = true;
setFocusedDate(next);
setViewMonth(startOfMonth(next));
}
function shiftMonth(n: number) {
const next = addMonthsClamped(focusedDate, n);
wantGridFocus.current = false;
setFocusedDate(next);
setViewMonth(startOfMonth(next));
}
function selectDate(d: Date) {
setSelected(d);
setInputValue(formatDisplay(d));
setFocusedDate(d);
setViewMonth(startOfMonth(d));
setOpen(false);
requestAnimationFrame(() => inputRef.current?.focus());
}
function clearDate() {
setSelected(null);
setInputValue("");
requestAnimationFrame(() => inputRef.current?.focus());
setOpen(false);
}
function commitInput() {
const parsed = parseDate(inputValue);
if (parsed) {
setSelected(parsed);
setInputValue(formatDisplay(parsed));
setFocusedDate(parsed);
setViewMonth(startOfMonth(parsed));
} else if (inputValue.trim() === "") {
setSelected(null);
} else {
setInputValue(selected ? formatDisplay(selected) : "");
}
}
function onInputChange(e: ChangeEvent<HTMLInputElement>) {
setInputValue(e.target.value);
}
function onInputKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
openCalendar();
} else if (e.key === "Enter") {
e.preventDefault();
commitInput();
} else if (e.key === "Escape" && open) {
e.preventDefault();
setOpen(false);
}
}
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 = addDays(focusedDate, -isoWeekday(focusedDate));
break;
case "End":
next = addDays(focusedDate, 6 - isoWeekday(focusedDate));
break;
case "PageUp":
next = addMonthsClamped(focusedDate, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonthsClamped(focusedDate, e.shiftKey ? 12 : 1);
break;
case "Enter":
case " ":
e.preventDefault();
selectDate(focusedDate);
return;
case "Escape":
e.preventDefault();
closeCalendar(true);
return;
default:
return;
}
if (next) {
e.preventDefault();
moveFocus(next);
}
}
const grid = buildGrid(viewMonth);
const weeks: Date[][] = [];
for (let i = 0; i < 6; i += 1) weeks.push(grid.slice(i * 7, i * 7 + 7));
const viewMonthIndex = viewMonth.getMonth();
const popTransition = reduce
? { duration: 0 }
: { duration: 0.16, ease: [0.22, 1, 0.36, 1] as const };
return (
<section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes calcmp-today-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.25); }
}
.calcmp-today-dot {
animation: calcmp-today-pulse 2.6s ease-in-out infinite;
}
@keyframes calcmp-ring-in {
from { opacity: 0; }
to { opacity: 1; }
}
.calcmp-selected-ring {
animation: calcmp-ring-in 0.28s ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.calcmp-today-dot,
.calcmp-selected-ring {
animation: none !important;
}
}
`}</style>
<div className="mx-auto max-w-md">
<div className="mb-8">
<p className="mb-2 inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 uppercase dark:border-indigo-500/30 dark:bg-zinc-900 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Product demo
</p>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Schedule your walkthrough
</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">
Pick a day and we’ll confirm a 30-minute slot by email.
Weekdays get you a same-week reply.
</p>
</div>
<div ref={rootRef} className="relative">
<label
id={labelId}
htmlFor={`${dialogId}-input`}
className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-zinc-300"
>
Preferred date
</label>
{/* field */}
<div className="group flex items-center gap-1 rounded-xl border border-slate-300 bg-white p-1 pl-3 shadow-sm transition-colors focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/40 dark:border-zinc-700 dark:bg-zinc-900 dark:focus-within:border-indigo-400">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0 text-slate-400 dark:text-zinc-500"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4.5" width="18" height="16" rx="2.5" />
<path d="M3 9h18M8 2.5v4M16 2.5v4" />
</svg>
<input
id={`${dialogId}-input`}
ref={inputRef}
type="text"
inputMode="numeric"
autoComplete="off"
spellCheck={false}
placeholder="e.g. Jul 24, 2026"
value={inputValue}
aria-labelledby={labelId}
aria-describedby={helpId}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
onBlur={commitInput}
className="min-w-0 flex-1 bg-transparent py-1.5 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-zinc-100 dark:placeholder:text-zinc-600"
/>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={dialogId}
aria-label={open ? "Close date picker" : "Open date picker"}
onClick={() => (open ? closeCalendar(false) : openCalendar())}
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4.5" width="18" height="16" rx="2.5" />
<path d="M3 9h18M8 2.5v4M16 2.5v4M7.5 13h.01M12 13h.01M16.5 13h.01M7.5 17h.01M12 17h.01" />
</svg>
</button>
</div>
<p id={helpId} className="mt-1.5 text-xs text-slate-400 dark:text-zinc-500">
{selected ? formatLong(selected) : "Type a date or press the calendar to browse."}
</p>
{/* popover */}
<AnimatePresence>
{open && (
<motion.div
id={dialogId}
role="dialog"
aria-modal="false"
aria-label="Choose a date"
initial={reduce ? { opacity: 1 } : { opacity: 0, y: -6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
transition={popTransition}
style={{ transformOrigin: "top left" }}
className="absolute z-20 mt-2 w-[19rem] rounded-2xl border border-slate-200 bg-white p-3 shadow-xl shadow-slate-900/10 dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/40"
>
{/* header */}
<div className="mb-2 flex items-center justify-between">
<button
type="button"
aria-label="Previous month"
onClick={() => shiftMonth(-1)}
className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 6l-6 6 6 6" />
</svg>
</button>
<div
id={captionId}
aria-live="polite"
className="text-sm font-semibold text-slate-800 tabular-nums dark:text-zinc-100"
>
{MONTHS[viewMonthIndex]} {viewMonth.getFullYear()}
</div>
<button
type="button"
aria-label="Next month"
onClick={() => shiftMonth(1)}
className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-900"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 6l6 6-6 6" />
</svg>
</button>
</div>
{/* grid */}
<div
ref={gridRef}
role="grid"
aria-labelledby={captionId}
onKeyDown={onGridKeyDown}
>
<div role="row" className="mb-1 grid grid-cols-7">
{WEEKDAYS_SHORT.map((wd, i) => (
<div
key={wd}
role="columnheader"
aria-label={WEEKDAYS_FULL[i]}
className="grid h-8 place-items-center text-[0.7rem] font-semibold tracking-wide text-slate-400 uppercase dark:text-zinc-500"
>
{wd}
</div>
))}
</div>
{weeks.map((week) => (
<div
role="row"
key={week[0].toISOString()}
className="grid grid-cols-7"
>
{week.map((day) => {
const inMonth = day.getMonth() === viewMonthIndex;
const isSelected = selected != null && isSameDay(day, selected);
const isToday = isSameDay(day, today);
const isFocused = isSameDay(day, focusedDate);
return (
<div role="gridcell" key={day.toISOString()} className="p-0.5">
<button
type="button"
tabIndex={isFocused ? 0 : -1}
data-focused={isFocused ? "true" : undefined}
aria-selected={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label={formatLong(day)}
onClick={() => selectDate(day)}
className={[
"relative grid h-9 w-full place-items-center rounded-lg text-sm tabular-nums transition-colors",
"focus-visible: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-zinc-900",
isSelected
? "bg-indigo-600 font-semibold text-white hover:bg-indigo-600 dark:bg-indigo-500 dark:text-white"
: inMonth
? "text-slate-700 hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-200 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-200"
: "text-slate-300 hover:bg-slate-100 dark:text-zinc-600 dark:hover:bg-zinc-800",
isToday && !isSelected
? "ring-1 ring-indigo-300 dark:ring-indigo-500/50"
: "",
].join(" ")}
>
{isSelected && (
<span
aria-hidden="true"
className="calcmp-selected-ring pointer-events-none absolute inset-0 rounded-lg ring-2 ring-indigo-300/60 dark:ring-indigo-400/40"
/>
)}
{day.getDate()}
{isToday && !isSelected && (
<span
aria-hidden="true"
className="calcmp-today-dot absolute bottom-1 h-1 w-1 rounded-full bg-indigo-500 dark:bg-indigo-400"
/>
)}
</button>
</div>
);
})}
</div>
))}
</div>
{/* footer */}
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2 dark:border-zinc-800">
<button
type="button"
onClick={() => selectDate(today)}
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-300 dark:hover:bg-indigo-500/15"
>
Today
</button>
<button
type="button"
onClick={clearDate}
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
>
Clear
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</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

