Web InnoventixFreeCode

Calendar Compact

Original · free

tiny inline date field calendar

byWeb InnoventixReact + Tailwind
calendarcompactcalendars
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-compact.json
calendar-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&rsquo;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 quote

Similar components

Browse all →