Mini Month
Inputs & Forms

Mini Month

A sidebar-sized glanceable month with event dots, sliding prev/next paging, arrow-key navigation, and click-to-emit dates.

Install

npx shadcn@latest add @paragon/mini-month

Also installs: calendar

mini-month.tsx

"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  addDays,
  addMonths,
  dateKey,
  MONTH_NAMES,
  monthGridDates,
  sameDay,
  startOfDay,
} from "@/registry/paragon/ui/calendar";
import { cn } from "@/lib/utils";

const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"] as const;
const WEEKDAY_NAMES = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
] as const;

const monthKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}`;

export interface MiniMonthEvent {
  date: Date;
  /** Any CSS color; defaults to the primary token. */
  color?: string;
}

export interface MiniMonthProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  month?: Date;
  defaultMonth?: Date;
  onMonthChange?: (month: Date) => void;
  selected?: Date | null;
  onSelect?: (date: Date) => void;
  /** Days that carry a dot marker (up to three per day). */
  events?: MiniMonthEvent[];
  /** Reference "today". Pass a fixed date for deterministic renders. */
  now?: Date;
  weekStartsOn?: 0 | 1;
  /** 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];

/**
 * A sidebar-sized month at a glance: 28px cells, event dots, sliding
 * prev/next paging, and full arrow-key navigation. Clicking a day just
 * emits the date — pair it with a schedule or agenda panel.
 */
export function MiniMonth({
  month: monthProp,
  defaultMonth,
  onMonthChange,
  selected: selectedProp,
  onSelect,
  events = [],
  now,
  weekStartsOn = 1,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel = "Mini month",
  ...props
}: MiniMonthProps) {
  const reduced = useReducedMotion() ?? false;
  const [fallbackToday] = React.useState(() => startOfDay(new Date()));
  const today = now ? startOfDay(now) : fallbackToday;

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

  const [monthState, setMonthState] = React.useState<Date>(
    () =>
      new Date(
        (defaultMonth ?? selected ?? today).getFullYear(),
        (defaultMonth ?? selected ?? today).getMonth(),
        1,
      ),
  );
  const visibleMonth = monthProp
    ? new Date(monthProp.getFullYear(), monthProp.getMonth(), 1)
    : monthState;
  const mKey = monthKey(visibleMonth);

  const [slide, setSlide] = React.useState({ key: mKey, dir: 0 });
  if (slide.key !== mKey) {
    const [py, pm] = slide.key.split("-").map(Number);
    setSlide({
      key: mKey,
      dir:
        visibleMonth.getFullYear() * 12 + visibleMonth.getMonth() >
        py * 12 + pm
          ? 1
          : -1,
    });
  }

  const navSourceRef = React.useRef<"pointer" | "keyboard">("pointer");
  const focusPendingRef = React.useRef<string | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [focusedDate, setFocusedDate] = React.useState<Date>(selected ?? today);

  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 = new Date(next.getFullYear(), next.getMonth(), 1);
    if (monthKey(start) === mKey) return;
    setMonthState(start);
    onMonthChange?.(start);
  };

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

  const dotsFor = React.useMemo(() => {
    const map = new Map<string, MiniMonthEvent[]>();
    for (const ev of events) {
      const k = dateKey(ev.date);
      const list = map.get(k) ?? [];
      if (list.length < 3) list.push(ev);
      map.set(k, list);
    }
    return map;
  }, [events]);

  const days = monthGridDates(visibleMonth, weekStartsOn);
  const weekdays = Array.from({ length: 7 }, (_, i) => (i + weekStartsOn) % 7);

  const tabbableKey = (() => {
    const inGrid = (d: Date | null | undefined) =>
      d && days.some((g) => sameDay(g, d)) ? dateKey(d) : null;
    return inGrid(focusedDate) ?? inGrid(selected) ?? inGrid(today) ?? dateKey(visibleMonth);
  })();

  const animate = !isStatic && !reduced && navSourceRef.current === "pointer";
  const label = `${MONTH_NAMES[visibleMonth.getMonth()].slice(0, 3)} ${visibleMonth.getFullYear()}`;

  return (
    <div
      ref={rootRef}
      className={cn("w-fit select-none", className)}
      {...props}
    >
      <div className="flex h-7 items-center justify-between gap-1 px-1">
        <span className="relative flex-1 overflow-hidden">
          <span aria-live="polite" className="sr-only">
            {label}
          </span>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={mKey}
              initial={{ x: animate ? slide.dir * 10 : 0, opacity: animate ? 0 : 1 }}
              animate={{ x: 0, opacity: 1 }}
              exit={{
                x: animate ? slide.dir * -10 : 0,
                opacity: 0,
                transition: { duration: animate ? 0.1 : 0, ease: EASE_EXIT },
              }}
              transition={{ duration: 0.16, ease: EASE_OUT }}
              className="block text-xs font-medium text-foreground"
              aria-hidden
            >
              {label}
            </motion.span>
          </AnimatePresence>
        </span>
        {([-1, 1] as const).map((dir) => (
          <button
            key={dir}
            type="button"
            aria-label={dir === -1 ? "Previous month" : "Next month"}
            onClick={() => changeMonth(addMonths(visibleMonth, dir), "pointer")}
            className="pressable relative flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color] duration-150 ease-out outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
          >
            {dir === -1 ? (
              <ChevronLeft className="size-3.5" aria-hidden />
            ) : (
              <ChevronRight className="size-3.5" aria-hidden />
            )}
          </button>
        ))}
      </div>

      <div role="grid" aria-label={`${ariaLabel}, ${label}`}>
        <div role="row" className="mt-0.5 grid grid-cols-7">
          {weekdays.map((w) => (
            <span
              key={w}
              role="columnheader"
              aria-label={WEEKDAY_NAMES[w]}
              className="flex h-6 w-7 items-center justify-center text-[10px] font-medium text-muted-foreground/70"
            >
              {WEEKDAY_INITIALS[w]}
            </span>
          ))}
        </div>
        <div className="relative h-[168px] overflow-hidden">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={mKey}
              data-month={mKey}
              initial={{ x: animate ? slide.dir * 12 : 0, opacity: animate ? 0 : 1 }}
              animate={{ x: 0, opacity: 1 }}
              exit={{
                x: animate ? slide.dir * -12 : 0,
                opacity: 0,
                transition: { duration: animate ? 0.12 : 0, ease: EASE_EXIT },
              }}
              transition={{ duration: 0.16, ease: EASE_OUT }}
              role="rowgroup"
            >
              {Array.from({ length: 6 }, (_, wi) => (
                <div key={wi} role="row" className="grid grid-cols-7">
                  {days.slice(wi * 7, wi * 7 + 7).map((d) => {
                    const key = dateKey(d);
                    const outside = monthKey(d) !== mKey;
                    const isToday = sameDay(d, today);
                    const isSelected = Boolean(selected && sameDay(d, selected));
                    const dots = dotsFor.get(key);
                    return (
                      <button
                        key={key}
                        type="button"
                        role="gridcell"
                        data-date={key}
                        tabIndex={key === tabbableKey ? 0 : -1}
                        aria-label={`${WEEKDAY_NAMES[d.getDay()]}, ${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}${dots ? `, ${dots.length} ${dots.length === 1 ? "event" : "events"}` : ""}`}
                        aria-selected={isSelected || undefined}
                        aria-current={isToday ? "date" : undefined}
                        onClick={() => {
                          setSelectedState(d);
                          onSelect?.(d);
                          setFocusedDate(d);
                          if (outside) changeMonth(d, "pointer");
                        }}
                        onFocus={() => setFocusedDate(d)}
                        onKeyDown={(e) => {
                          const jump: Record<string, number> = {
                            ArrowLeft: -1,
                            ArrowRight: 1,
                            ArrowUp: -7,
                            ArrowDown: 7,
                          };
                          if (e.key in jump) {
                            e.preventDefault();
                            focusDay(addDays(d, jump[e.key]));
                          } else if (e.key === "PageUp" || e.key === "PageDown") {
                            e.preventDefault();
                            focusDay(addMonths(d, e.key === "PageUp" ? -1 : 1));
                          }
                        }}
                        className={cn(
                          "group relative flex size-7 items-center justify-center rounded-md text-xs tabular-nums outline-none",
                          outside ? "text-muted-foreground/40" : "text-foreground",
                        )}
                      >
                        {isToday && !isSelected && (
                          <span
                            aria-hidden
                            className="absolute inset-0.5 rounded-[5px] ring-1 ring-ring/60 ring-inset"
                          />
                        )}
                        <span
                          aria-hidden
                          className={cn(
                            "absolute inset-0.5 rounded-[5px] bg-primary transition-[scale,opacity] duration-150 ease-out motion-reduce:scale-100",
                            isSelected ? "scale-100 opacity-100" : "scale-75 opacity-0",
                          )}
                        />
                        {!isSelected && (
                          <span
                            aria-hidden
                            className="absolute inset-0.5 rounded-[5px] 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",
                            isSelected && "font-medium text-primary-foreground",
                          )}
                        >
                          {d.getDate()}
                        </span>
                        {dots && (
                          <span
                            aria-hidden
                            className="absolute bottom-[3px] left-1/2 z-10 flex -translate-x-1/2 gap-[2px]"
                          >
                            {dots.map((ev, i) => (
                              <span
                                key={i}
                                className={cn(
                                  "size-1 rounded-full",
                                  isSelected
                                    ? "bg-primary-foreground"
                                    : !ev.color && "bg-primary",
                                )}
                                style={
                                  !isSelected && ev.color
                                    ? { backgroundColor: ev.color }
                                    : undefined
                                }
                              />
                            ))}
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
              ))}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}