Web InnoventixFreeCode

Calendar Month Year

Original · free

month and year quick selector

byWeb InnoventixReact + Tailwind
calendarmonthyearcalendars
Open

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.json
calendar-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 &middot; 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} &ndash; {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 quote

Similar components

Browse all →