Calendar Month Year
Original · freemonth and year quick selector
byWeb InnoventixReact + Tailwind
calendarmonthyearcalendars
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-month-year.jsoncalendar-month-year.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
} from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
const MONTHS_LONG = [
"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 = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MIN_YEAR = 1900;
const MAX_YEAR = 2100;
const ARROW_KEYS = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "Home", "End"];
type Tab = "month" | "year";
function clampYear(y: number): number {
return Math.min(MAX_YEAR, Math.max(MIN_YEAR, y));
}
function clampStart(s: number): number {
return Math.min(MAX_YEAR - 11, Math.max(MIN_YEAR, s));
}
function pageStartFor(year: number): number {
return clampStart(year - (((year % 12) + 12) % 12));
}
function gridMove(current: number, key: string): number {
const cols = 3;
const last = 11;
switch (key) {
case "ArrowRight":
return Math.min(last, current + 1);
case "ArrowLeft":
return Math.max(0, current - 1);
case "ArrowDown":
return Math.min(last, current + cols);
case "ArrowUp":
return Math.max(0, current - cols);
case "Home":
return 0;
case "End":
return last;
default:
return current;
}
}
export default function CalendarMonthYear() {
const reduce = useReducedMotion();
const uid = useId();
const monthTabId = `${uid}-month-tab`;
const yearTabId = `${uid}-year-tab`;
const panelId = `${uid}-panel`;
const [today] = useState(() => new Date());
const todayMonth = today.getMonth();
const todayYear = today.getFullYear();
const [selMonth, setSelMonth] = useState(todayMonth);
const [selYear, setSelYear] = useState(todayYear);
const [tab, setTab] = useState<Tab>("month");
const [viewYear, setViewYear] = useState(todayYear);
const [yearPageStart, setYearPageStart] = useState(() => pageStartFor(todayYear));
const [dir, setDir] = useState(1);
const [announce, setAnnounce] = useState("");
const [monthFocus, setMonthFocus] = useState(todayMonth);
const [yearFocus, setYearFocus] = useState(() => todayYear - pageStartFor(todayYear));
const monthBtnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const yearBtnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const monthTabRef = useRef<HTMLButtonElement | null>(null);
const yearTabRef = useRef<HTMLButtonElement | null>(null);
const gridFocusRef = useRef<{ grid: Tab; index: number } | null>(null);
// Re-home keyboard focus inside a freshly-mounted grid (after paging remounts it).
useEffect(() => {
const target = gridFocusRef.current;
if (!target) return;
const refs = target.grid === "month" ? monthBtnRefs : yearBtnRefs;
const el = refs.current[target.index];
if (el) {
el.focus();
gridFocusRef.current = null;
}
});
const daysInMonth = new Date(selYear, selMonth + 1, 0).getDate();
const firstWeekday = new Date(selYear, selMonth, 1).getDay();
const years = Array.from({ length: 12 }, (_, i) => yearPageStart + i);
function changeTab(next: Tab) {
if (next === tab) return;
setDir(next === "year" ? 1 : -1);
if (next === "year") {
const start = pageStartFor(viewYear);
setYearPageStart(start);
setYearFocus(viewYear - start);
} else {
setMonthFocus(selMonth);
}
setTab(next);
}
function onTabsKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const k = e.key;
if (k !== "ArrowRight" && k !== "ArrowLeft" && k !== "Home" && k !== "End") return;
e.preventDefault();
const next: Tab =
k === "Home" ? "month" : k === "End" ? "year" : tab === "month" ? "year" : "month";
changeTab(next);
const ref = next === "month" ? monthTabRef : yearTabRef;
requestAnimationFrame(() => ref.current?.focus());
}
function page(delta: -1 | 1) {
setDir(delta);
if (tab === "month") setViewYear((y) => clampYear(y + delta));
else setYearPageStart((s) => clampStart(s + delta * 12));
}
function onGridKeyDown(e: KeyboardEvent<HTMLDivElement>, grid: Tab) {
const k = e.key;
const focus = grid === "month" ? monthFocus : yearFocus;
const setFocus = grid === "month" ? setMonthFocus : setYearFocus;
const refs = grid === "month" ? monthBtnRefs : yearBtnRefs;
if (ARROW_KEYS.includes(k)) {
e.preventDefault();
const next = gridMove(focus, k);
setFocus(next);
refs.current[next]?.focus();
return;
}
if (k === "PageUp" || k === "PageDown") {
e.preventDefault();
const delta: -1 | 1 = k === "PageUp" ? -1 : 1;
setDir(delta);
if (grid === "month") setViewYear((y) => clampYear(y + delta));
else setYearPageStart((s) => clampStart(s + delta * 12));
gridFocusRef.current = { grid, index: focus };
}
}
function commitMonth(i: number) {
setSelMonth(i);
setSelYear(viewYear);
setMonthFocus(i);
setAnnounce(`Selected ${MONTHS_LONG[i]} ${viewYear}`);
}
function commitYear(y: number) {
setSelYear(y);
setViewYear(y);
setDir(-1);
setMonthFocus(selMonth);
setTab("month");
gridFocusRef.current = { grid: "month", index: selMonth };
setAnnounce(`Year ${y} selected, now choose a month`);
}
function goToday() {
setDir(1);
setSelMonth(todayMonth);
setSelYear(todayYear);
setViewYear(todayYear);
setYearPageStart(pageStartFor(todayYear));
setMonthFocus(todayMonth);
setTab("month");
setAnnounce(`Jumped to ${MONTHS_LONG[todayMonth]} ${todayYear}`);
}
const prevDisabled = tab === "month" ? viewYear <= MIN_YEAR : yearPageStart <= MIN_YEAR;
const nextDisabled =
tab === "month" ? viewYear >= MAX_YEAR : yearPageStart >= MAX_YEAR - 11;
const panelVariants: Variants = {
enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 18 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -18 }),
};
const cellBase =
"cmy-cell relative flex h-11 items-center justify-center rounded-xl text-sm font-medium tabular-nums outline-none transition active:scale-95 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-900";
function cellClass(isSel: boolean, isToday: boolean): string {
if (isSel) {
return `${cellBase} bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 hover:bg-indigo-500`;
}
const idle =
" text-slate-700 hover:bg-slate-100 dark:text-zinc-300 dark:hover:bg-zinc-800";
const ring = isToday ? " ring-1 ring-inset ring-amber-400 dark:ring-amber-500/70" : "";
return cellBase + idle + ring;
}
return (
<section className="relative w-full bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:px-6 dark:from-zinc-950 dark:to-zinc-900">
<style>{`
@keyframes cmy-dot-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.75); } }
.cmy-dot { animation: cmy-dot-pulse 2.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cmy-dot { animation: none !important; }
.cmy-cell { transition: none !important; transform: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-md">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-xl shadow-slate-900/5 sm:p-6 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
{/* Selection summary */}
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Quick jump
</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
{MONTHS_LONG[selMonth]} {selYear}
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-zinc-400">
{daysInMonth} days · starts on {WEEKDAYS[firstWeekday]}
</p>
</div>
<div className="flex h-12 w-12 shrink-0 flex-col items-center justify-center rounded-2xl bg-indigo-50 ring-1 ring-inset ring-indigo-100 dark:bg-indigo-500/10 dark:ring-indigo-400/20">
<span className="text-[9px] font-bold uppercase tracking-wide text-indigo-500 dark:text-indigo-300">
{MONTHS_SHORT[todayMonth]}
</span>
<span className="text-lg font-bold leading-none text-indigo-700 tabular-nums dark:text-indigo-200">
{today.getDate()}
</span>
</div>
</div>
{/* Tabs */}
<div
role="tablist"
aria-label="Choose selector view"
onKeyDown={onTabsKeyDown}
className="mb-4 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1 dark:bg-zinc-800/70"
>
{(["month", "year"] as const).map((t) => {
const active = tab === t;
return (
<button
key={t}
ref={t === "month" ? monthTabRef : yearTabRef}
type="button"
role="tab"
id={t === "month" ? monthTabId : yearTabId}
aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
onClick={() => changeTab(t)}
className={
"cmy-cell rounded-lg px-3 py-2 text-sm font-medium capitalize outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-zinc-800 " +
(active
? "bg-white text-slate-900 shadow-sm dark:bg-zinc-900 dark:text-white"
: "text-slate-500 hover:text-slate-700 dark:text-zinc-400 dark:hover:text-zinc-200")
}
>
{t}
</button>
);
})}
</div>
{/* Pager */}
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() => page(-1)}
disabled={prevDisabled}
aria-label={tab === "month" ? "Previous year" : "Previous 12 years"}
className="cmy-cell flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
{tab === "month" ? (
<button
type="button"
onClick={() => {
changeTab("year");
requestAnimationFrame(() => yearTabRef.current?.focus());
}}
aria-label={`Browsing ${viewYear}. Pick a different year`}
className="cmy-cell rounded-lg px-3 py-1.5 text-sm font-semibold tabular-nums text-slate-800 outline-none transition hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-100 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
{viewYear}
</button>
) : (
<span className="px-3 py-1.5 text-sm font-semibold tabular-nums text-slate-800 dark:text-zinc-100">
{yearPageStart} – {yearPageStart + 11}
</span>
)}
<button
type="button"
onClick={() => page(1)}
disabled={nextDisabled}
aria-label={tab === "month" ? "Next year" : "Next 12 years"}
className="cmy-cell flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
{/* Panel */}
<AnimatePresence mode="wait" custom={dir} initial={false}>
<motion.div
key={tab === "month" ? `m${viewYear}` : `y${yearPageStart}`}
custom={dir}
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduce ? 0 : 0.22, ease: [0.22, 0.61, 0.36, 1] }}
role="tabpanel"
id={panelId}
aria-labelledby={tab === "month" ? monthTabId : yearTabId}
>
{tab === "month" ? (
<div
role="grid"
aria-label={`Months in ${viewYear}`}
onKeyDown={(e) => onGridKeyDown(e, "month")}
className="grid grid-cols-3 gap-2"
>
{[0, 1, 2, 3].map((r) => (
<div role="row" key={r} className="contents">
{[0, 1, 2].map((c) => {
const i = r * 3 + c;
const isSel = selMonth === i && selYear === viewYear;
const isToday = i === todayMonth && viewYear === todayYear;
return (
<button
key={i}
ref={(el) => {
monthBtnRefs.current[i] = el;
}}
type="button"
role="gridcell"
aria-selected={isSel}
aria-current={isToday ? "date" : undefined}
aria-label={`${MONTHS_LONG[i]} ${viewYear}`}
tabIndex={i === monthFocus ? 0 : -1}
onClick={() => commitMonth(i)}
className={cellClass(isSel, isToday)}
>
{MONTHS_SHORT[i]}
{isToday && (
<span
aria-hidden="true"
className={
"cmy-dot absolute bottom-1.5 h-1 w-1 rounded-full " +
(isSel ? "bg-white" : "bg-amber-500 dark:bg-amber-400")
}
/>
)}
</button>
);
})}
</div>
))}
</div>
) : (
<div
role="grid"
aria-label={`Years ${yearPageStart} to ${yearPageStart + 11}`}
onKeyDown={(e) => onGridKeyDown(e, "year")}
className="grid grid-cols-3 gap-2"
>
{[0, 1, 2, 3].map((r) => (
<div role="row" key={r} className="contents">
{[0, 1, 2].map((c) => {
const i = r * 3 + c;
const y = years[i];
const isSel = selYear === y;
const isToday = y === todayYear;
return (
<button
key={i}
ref={(el) => {
yearBtnRefs.current[i] = el;
}}
type="button"
role="gridcell"
aria-selected={isSel}
aria-current={isToday ? "date" : undefined}
aria-label={`${y}`}
tabIndex={i === yearFocus ? 0 : -1}
onClick={() => commitYear(y)}
className={cellClass(isSel, isToday)}
>
{y}
{isToday && (
<span
aria-hidden="true"
className={
"cmy-dot absolute bottom-1.5 h-1 w-1 rounded-full " +
(isSel ? "bg-white" : "bg-amber-500 dark:bg-amber-400")
}
/>
)}
</button>
);
})}
</div>
))}
</div>
)}
</motion.div>
</AnimatePresence>
{/* Footer */}
<div className="mt-4 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-zinc-800">
<button
type="button"
onClick={goToday}
className="cmy-cell inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-indigo-600 outline-none transition hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:bg-indigo-500/10 dark:focus-visible:ring-offset-zinc-900"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="2.5" fill="currentColor" stroke="none" />
</svg>
Today
</button>
<p className="text-xs text-slate-500 dark:text-zinc-400">
Selected{" "}
<span className="font-semibold tabular-nums text-slate-700 dark:text-zinc-200">
{MONTHS_SHORT[selMonth]} {selYear}
</span>
</p>
</div>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</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 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

