Calendar
Inputs & Forms

Calendar

The foundation month grid: stable 6-row geometry, roving-tabindex keyboard navigation, direction-aware month slides, and a range mode with live hover preview.

Install

npx shadcn@latest add @paragon/calendar

calendar.tsx

"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/* ------------------------------------------------------------------
 * Hand-rolled date math — no date libraries. All arithmetic works on
 * local calendar components (year/month/day); day differences go
 * through Date.UTC so DST shifts can never skew a comparison.
 * ------------------------------------------------------------------ */

export const MONTH_NAMES = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
const WEEKDAY_NAMES = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
] as const;

export function startOfDay(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}

export function addDays(d: Date, n: number): Date {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
}

/** Adds months, clamping the day-of-month to the target month's length. */
export function addMonths(d: Date, n: number): Date {
  const y = d.getFullYear();
  const m = d.getMonth() + n;
  const last = new Date(y, m + 1, 0).getDate();
  return new Date(y, m, Math.min(d.getDate(), last));
}

/** Negative / zero / positive when a is before / same day / after b. */
export function compareDay(a: Date, b: Date): number {
  return (
    Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()) -
    Date.UTC(b.getFullYear(), b.getMonth(), b.getDate())
  );
}

export function sameDay(a: Date, b: Date): boolean {
  return compareDay(a, b) === 0;
}

export function dateKey(d: Date): string {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

/** "Jul 6, 2026" — deterministic, locale-independent. */
export function formatDate(d: Date): string {
  return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}`;
}

function monthKey(d: Date): string {
  return `${d.getFullYear()}-${d.getMonth()}`;
}

function startOfMonth(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), 1);
}

function startOfWeek(d: Date, weekStartsOn: 0 | 1): Date {
  return addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
}

/**
 * The 42 dates (6 stable rows x 7 columns) covering a month. A fixed
 * 6-row grid means February and a 31-day month occupy identical
 * heights — no layout jump while paging.
 */
export function monthGridDates(month: Date, weekStartsOn: 0 | 1): Date[] {
  const first = startOfWeek(startOfMonth(month), weekStartsOn);
  return Array.from({ length: 42 }, (_, i) => addDays(first, i));
}

function ariaDayLabel(d: Date): string {
  return `${WEEKDAY_NAMES[d.getDay()]}, ${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}

function clampDate(d: Date, min?: Date, max?: Date): Date {
  if (min && compareDay(d, min) < 0) return startOfDay(min);
  if (max && compareDay(d, max) > 0) return startOfDay(max);
  return d;
}

/* ------------------------------------------------------------------ */

export interface CalendarRange {
  from: Date | null;
  to: Date | null;
}

export interface CalendarProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  mode?: "single" | "range";
  /** Controlled selection (single mode). */
  selected?: Date | null;
  defaultSelected?: Date | null;
  onSelect?: (date: Date) => void;
  /** Controlled selection (range mode). */
  range?: CalendarRange;
  defaultRange?: CalendarRange;
  onRangeChange?: (range: CalendarRange) => void;
  /** Controlled visible month. */
  month?: Date;
  defaultMonth?: Date;
  onMonthChange?: (month: Date) => void;
  min?: Date;
  max?: Date;
  /** Per-date disabling, on top of min/max. */
  isDateDisabled?: (date: Date) => boolean;
  /** 0 = Sunday, 1 = Monday. */
  weekStartsOn?: 0 | 1;
  /** Reference "today". Pass a fixed date for deterministic renders. */
  now?: Date;
  /** Which month-nav chevrons to render (multi-pane layouts trim them). */
  nav?: "both" | "prev" | "next" | "none";
  /** Render outside-month cells as empty placeholders (multi-pane ranges). */
  hideOutsideDays?: boolean;
  /** Controlled hover date — lets multi-pane pickers share range preview. */
  hoverDate?: Date | null;
  onHoverDateChange?: (date: Date | null) => void;
  /** Disables the month slide transition. */
  static?: boolean;
}

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

interface SlideCustom {
  dir: number;
  animate: boolean;
}

const gridVariants = {
  enter: (c: SlideCustom) => ({
    x: c.animate ? c.dir * 16 : 0,
    opacity: c.animate ? 0 : 1,
  }),
  center: { x: 0, opacity: 1 },
  exit: (c: SlideCustom) => ({
    x: c.animate ? c.dir * -16 : 0,
    opacity: 0,
    transition: { duration: c.animate ? 0.15 : 0, ease: EASE_EXIT },
  }),
};

const labelVariants = {
  enter: (c: SlideCustom) => ({
    x: c.animate ? c.dir * 12 : 0,
    opacity: c.animate ? 0 : 1,
  }),
  center: { x: 0, opacity: 1 },
  exit: (c: SlideCustom) => ({
    x: c.animate ? c.dir * -12 : 0,
    opacity: 0,
    transition: { duration: c.animate ? 0.12 : 0, ease: EASE_EXIT },
  }),
};

/**
 * The foundation month grid: exact 7-column alignment on a stable 6-row
 * body, roving-tabindex keyboard navigation (arrows, PageUp/Down, Home,
 * End), direction-aware month slides that stay interruptible, and a
 * range mode with live hover preview for pickers to compose.
 */
export function Calendar({
  mode = "single",
  selected: selectedProp,
  defaultSelected = null,
  onSelect,
  range: rangeProp,
  defaultRange = { from: null, to: null },
  onRangeChange,
  month: monthProp,
  defaultMonth,
  onMonthChange,
  min,
  max,
  isDateDisabled,
  weekStartsOn = 1,
  now,
  nav = "both",
  hideOutsideDays = false,
  hoverDate: hoverProp,
  onHoverDateChange,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel = "Calendar",
  ...props
}: CalendarProps) {
  const reduced = useReducedMotion() ?? false;
  // Stable per-mount fallback; demos pass `now` so renders stay deterministic.
  const [fallbackToday] = React.useState(() => startOfDay(new Date()));
  const today = now ? startOfDay(now) : fallbackToday;

  const [selectedState, setSelectedState] = React.useState<Date | null>(
    defaultSelected,
  );
  const selected = selectedProp !== undefined ? selectedProp : selectedState;

  const [rangeState, setRangeState] = React.useState<CalendarRange>(
    defaultRange,
  );
  const range = rangeProp !== undefined ? rangeProp : rangeState;

  const [monthState, setMonthState] = React.useState<Date>(() =>
    startOfMonth(
      defaultMonth ??
        (mode === "range" ? (range.from ?? today) : (selected ?? today)),
    ),
  );
  const visibleMonth = startOfMonth(monthProp ?? monthState);
  const mKey = monthKey(visibleMonth);

  const [hoverState, setHoverState] = React.useState<Date | null>(null);
  const hoverDate = hoverProp !== undefined ? hoverProp : hoverState;
  const setHover = (d: Date | null) => {
    setHoverState(d);
    onHoverDateChange?.(d);
  };

  const [focusedDate, setFocusedDate] = React.useState<Date>(
    () => selected ?? range.from ?? today,
  );

  // Direction is derived when the visible month changes — works for both
  // internal navigation and externally controlled `month` updates.
  const [slide, setSlide] = React.useState({ key: mKey, dir: 0 });
  if (slide.key !== mKey) {
    const [py, pm] = slide.key.split("-").map(Number);
    const dir =
      visibleMonth.getFullYear() * 12 + visibleMonth.getMonth() >
      py * 12 + pm
        ? 1
        : -1;
    setSlide({ key: mKey, dir });
  }

  // Keyboard month changes render instantly (never animate keyboard actions).
  const navSourceRef = React.useRef<"pointer" | "keyboard">("pointer");
  const focusPendingRef = React.useRef<string | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const key = focusPendingRef.current;
    if (!key) return;
    focusPendingRef.current = null;
    rootRef.current
      ?.querySelector<HTMLButtonElement>(
        `[data-month="${mKey}"] [data-date="${key}"]`,
      )
      ?.focus();
  });

  const changeMonth = (next: Date, source: "pointer" | "keyboard") => {
    navSourceRef.current = source;
    const start = startOfMonth(next);
    if (monthKey(start) === mKey) return;
    setMonthState(start);
    onMonthChange?.(start);
  };

  const isDisabled = (d: Date) =>
    Boolean(
      (min && compareDay(d, min) < 0) ||
        (max && compareDay(d, max) > 0) ||
        isDateDisabled?.(d),
    );

  const selectDay = (d: Date) => {
    if (isDisabled(d)) return;
    if (mode === "single") {
      setSelectedState(d);
      onSelect?.(d);
    } else {
      let next: CalendarRange;
      if (!range.from || (range.from && range.to)) {
        next = { from: d, to: null };
      } else if (compareDay(d, range.from) < 0) {
        next = { from: d, to: range.from };
      } else {
        next = { from: range.from, to: d };
      }
      setRangeState(next);
      onRangeChange?.(next);
    }
    if (monthKey(d) !== mKey) changeMonth(d, "pointer");
    setFocusedDate(d);
  };

  const focusDay = (target: Date) => {
    const next = clampDate(target, min, max);
    setFocusedDate(next);
    focusPendingRef.current = dateKey(next);
    if (monthKey(next) !== mKey) changeMonth(next, "keyboard");
  };

  const onDayKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, d: Date) => {
    let target: Date | null = null;
    switch (e.key) {
      case "ArrowLeft":
        target = addDays(d, -1);
        break;
      case "ArrowRight":
        target = addDays(d, 1);
        break;
      case "ArrowUp":
        target = addDays(d, -7);
        break;
      case "ArrowDown":
        target = addDays(d, 7);
        break;
      case "Home":
        target = startOfWeek(d, weekStartsOn);
        break;
      case "End":
        target = addDays(startOfWeek(d, weekStartsOn), 6);
        break;
      case "PageUp":
        target = addMonths(d, e.shiftKey ? -12 : -1);
        break;
      case "PageDown":
        target = addMonths(d, e.shiftKey ? 12 : 1);
        break;
    }
    if (target) {
      e.preventDefault();
      focusDay(target);
    }
  };

  const days = monthGridDates(visibleMonth, weekStartsOn);
  const weeks = Array.from({ length: 6 }, (_, i) => days.slice(i * 7, i * 7 + 7));
  const weekdays = Array.from(
    { length: 7 },
    (_, i) => WEEKDAY_LABELS[(i + weekStartsOn) % 7],
  );

  // Band = committed range, or the live preview while picking the far end.
  let bandStart: Date | null = null;
  let bandEnd: Date | null = null;
  if (mode === "range" && range.from) {
    const other = range.to ?? hoverDate;
    if (other) {
      bandStart = compareDay(range.from, other) <= 0 ? range.from : other;
      bandEnd = compareDay(range.from, other) <= 0 ? other : range.from;
    } else {
      bandStart = bandEnd = range.from;
    }
  }

  // Roving tabindex target: the focused date if visible, else selection,
  // else today, else the 1st of the visible month.
  const tabbableKey = (() => {
    const inGrid = (d: Date | null | undefined) =>
      d && days.some((g) => sameDay(g, d)) ? dateKey(d) : null;
    return (
      inGrid(focusedDate) ??
      inGrid(mode === "range" ? range.from : selected) ??
      inGrid(today) ??
      dateKey(visibleMonth)
    );
  })();

  const prevDisabled = Boolean(min && compareDay(addDays(visibleMonth, -1), min) < 0);
  const nextDisabled = Boolean(
    max && compareDay(addMonths(visibleMonth, 1), max) > 0,
  );

  const animate = !isStatic && !reduced && navSourceRef.current === "pointer";
  const custom: SlideCustom = { dir: slide.dir, animate };
  const monthLabel = `${MONTH_NAMES[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;

  const navButton = (dir: -1 | 1) => (
    <button
      type="button"
      aria-label={dir === -1 ? "Previous month" : "Next month"}
      disabled={dir === -1 ? prevDisabled : nextDisabled}
      onClick={() => changeMonth(addMonths(visibleMonth, dir), "pointer")}
      className="pressable flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color] duration-150 ease-out outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
    >
      {dir === -1 ? (
        <ChevronLeft className="size-4" aria-hidden />
      ) : (
        <ChevronRight className="size-4" aria-hidden />
      )}
    </button>
  );

  return (
    <div
      ref={rootRef}
      className={cn("w-fit select-none", className)}
      {...props}
    >
      <div className="flex h-8 items-center justify-between gap-2 px-1">
        <span className="relative flex-1 overflow-hidden">
          <span aria-live="polite" className="sr-only">
            {monthLabel}
          </span>
          <AnimatePresence mode="popLayout" initial={false} custom={custom}>
            <motion.span
              key={mKey}
              custom={custom}
              variants={labelVariants}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ duration: 0.18, ease: EASE_OUT }}
              className="block text-sm font-medium text-foreground"
              aria-hidden
            >
              {monthLabel}
            </motion.span>
          </AnimatePresence>
        </span>
        {nav !== "none" && (
          <span className="flex shrink-0 items-center gap-0.5">
            {(nav === "both" || nav === "prev") && navButton(-1)}
            {(nav === "both" || nav === "next") && navButton(1)}
          </span>
        )}
      </div>

      <div role="grid" aria-label={`${ariaLabel}, ${monthLabel}`}>
        <div role="row" className="mt-1 grid grid-cols-7">
          {weekdays.map((w, i) => (
            <span
              key={w}
              role="columnheader"
              aria-label={WEEKDAY_NAMES[(i + weekStartsOn) % 7]}
              className="flex h-8 w-9 items-center justify-center text-xs font-medium text-muted-foreground"
            >
              {w}
            </span>
          ))}
        </div>

        <div className="relative h-[216px] overflow-hidden">
          <AnimatePresence mode="popLayout" initial={false} custom={custom}>
            <motion.div
              key={mKey}
              data-month={mKey}
              custom={custom}
              variants={gridVariants}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ duration: 0.18, ease: EASE_OUT }}
              role="rowgroup"
              onPointerLeave={() => setHover(null)}
            >
              {weeks.map((week, wi) => (
                <div key={wi} role="row" className="grid grid-cols-7">
                  {week.map((d) => {
                    const outside = monthKey(d) !== mKey;
                    if (outside && hideOutsideDays) {
                      return (
                        <span
                          key={dateKey(d)}
                          role="gridcell"
                          aria-hidden
                          className="size-9"
                        />
                      );
                    }
                    const key = dateKey(d);
                    const disabled = isDisabled(d);
                    const isToday = sameDay(d, today);
                    const isSelected =
                      mode === "single"
                        ? Boolean(selected && sameDay(d, selected))
                        : Boolean(
                            (range.from && sameDay(d, range.from)) ||
                              (range.to && sameDay(d, range.to)),
                          );
                    const inBand = Boolean(
                      bandStart &&
                        bandEnd &&
                        compareDay(d, bandStart) >= 0 &&
                        compareDay(d, bandEnd) <= 0 &&
                        !sameDay(bandStart, bandEnd),
                    );
                    const isBandStart = Boolean(
                      inBand && bandStart && sameDay(d, bandStart),
                    );
                    const isBandEnd = Boolean(
                      inBand && bandEnd && sameDay(d, bandEnd),
                    );
                    return (
                      <button
                        key={key}
                        type="button"
                        role="gridcell"
                        data-date={key}
                        tabIndex={key === tabbableKey ? 0 : -1}
                        aria-label={ariaDayLabel(d)}
                        aria-selected={isSelected || undefined}
                        aria-disabled={disabled || undefined}
                        aria-current={isToday ? "date" : undefined}
                        onKeyDown={(e) => onDayKeyDown(e, d)}
                        onClick={() => selectDay(d)}
                        onPointerEnter={() => setHover(d)}
                        onFocus={() => {
                          setFocusedDate(d);
                          if (mode === "range") setHover(d);
                        }}
                        className={cn(
                          "group relative size-9 rounded-lg text-[13px] tabular-nums outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring",
                          disabled
                            ? "cursor-default text-muted-foreground/40"
                            : outside
                              ? "text-muted-foreground/50"
                              : "text-foreground",
                        )}
                      >
                        {/* Range band — square so adjacent cells read as one bar. */}
                        {inBand && !disabled && (
                          <span
                            aria-hidden
                            className={cn(
                              "absolute inset-y-0.5 bg-accent",
                              isBandStart
                                ? "right-0 left-1/2"
                                : isBandEnd
                                  ? "right-1/2 left-0"
                                  : "inset-x-0",
                            )}
                          />
                        )}
                        {/* Today ring, beneath the selection fill. */}
                        {isToday && (
                          <span
                            aria-hidden
                            className="absolute inset-0.5 rounded-[7px] ring-1 ring-ring/60 ring-inset"
                          />
                        )}
                        {/* Selection fill grows in over 150ms; the transition
                            retargets mid-flight when selection moves. */}
                        <span
                          aria-hidden
                          className={cn(
                            "absolute inset-0.5 rounded-[7px] bg-primary transition-[scale,opacity] duration-150 ease-out motion-reduce:scale-100",
                            isSelected ? "scale-100 opacity-100" : "scale-75 opacity-0",
                          )}
                        />
                        {/* Hover wash (kept under text, above band). */}
                        {!disabled && !isSelected && (
                          <span
                            aria-hidden
                            className="absolute inset-0.5 rounded-[7px] bg-accent opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100 group-focus-visible:opacity-100"
                          />
                        )}
                        <span
                          className={cn(
                            "relative z-10 transition-colors duration-150 ease-out",
                            isSelected && "font-medium text-primary-foreground",
                          )}
                        >
                          {d.getDate()}
                        </span>
                      </button>
                    );
                  })}
                </div>
              ))}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}