Web InnoventixFreeCode

Calendar Date Picker

Original · free

date picker with a dropdown calendar

byWeb InnoventixReact + Tailwind
calendardatepickercalendars
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-date-picker.json
calendar-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&rsquo;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 quote

Similar components

Browse all →