← All components

Calendar

A month to pick a day or a stay from, that slides a short way when you turn the page.

October 2026

4 nights

The idea

Chosen days are ink tiles that land with a small settle; the days between a range's ends sit on one grey band running under them edge to edge, so a stay reads as one thing rather than a row of separately selected squares, and while the second end is open the band follows the pointer at half strength. Today is a dot, not a colour. Turning the month slides only the grid, a short way in the direction of travel, because you page through several in a row. It is an ARIA grid with one tab stop, and every name comes from Intl — no date library.

Composes Button.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Calendar — usage17 lines
import { Calendar, type DateRange } from "mihirmodi-ui";

// One day.
<Calendar value={day} onChange={setDay} />

// A stretch of days. `to` is null while the second end is being picked.
const [stay, setStay] = useState<DateRange | null>(null);

<Calendar
  mode="range"
  value={stay}
  onChange={setStay}
  min={new Date()}
  weekStartsOn={1}
  locale="en-GB"
  isDisabled={(d) => booked.has(d.toDateString())}
/>

Props

PropNotes
mode"single" | "range"One day, or a `{ from, to }` stretch. `to` is null while the second end is open.
value / onChangeDate | null or DateRange | null / (value) => voidControlled, or `defaultValue`.
month / onMonthChangeDate / (month) => voidThe month on show, if you want to own it. Otherwise it opens on the chosen day.
weekStartsOn0–60 is Sunday. 1 for most of Europe.
localestringFor month and day names. Pass one when rendering on the server, so both sides agree.
min / max / isDisabledDate / Date / (date) => booleanDays outside or disabled are struck through and refuse a click, but the keyboard still passes over them.
autoFocusbooleanFocus the chosen day on mount — what DatePicker uses when it opens.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/forms/Calendar.tsx353 lines
"use client";

import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion, type Variants } from "motion/react";
import { Button } from "../actions/Button.js";
import { ChevronDownIcon } from "../../lib/glyph.js";
import { CLOSE, EASE_OUT_EXPO, FLAT, LIFT } from "../../lib/motion.js";

/** A stretch of days. `to` is null while the second end is still being picked. */
export type DateRange = { from: Date | null; to: Date | null };

/** 0 is Sunday, as `Date.getDay()` counts. */
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;

type SingleCalendar = {
  mode?: "single";
  value?: Date | null;
  defaultValue?: Date | null;
  onChange?: (date: Date) => void;
};
type RangeCalendar = {
  mode: "range";
  value?: DateRange | null;
  defaultValue?: DateRange | null;
  onChange?: (range: DateRange) => void;
};

export type CalendarProps = (SingleCalendar | RangeCalendar) & {
  /** The month on show, controlled. Any day in it will do. */
  month?: Date;
  defaultMonth?: Date;
  onMonthChange?: (month: Date) => void;
  weekStartsOn?: Weekday;
  /** For month and day names. Pass one when rendering on the server, so both sides agree. */
  locale?: string;
  min?: Date;
  max?: Date;
  /** Days that cannot be chosen — weekends, booked nights. Still reachable by keyboard. */
  isDisabled?: (date: Date) => boolean;
  /** Show the neighbouring months' days in the first and last rows, faint and inert. */
  showOutsideDays?: boolean;
  /** Focus the chosen day (or today) on mount — what a picker wants when it opens. */
  autoFocus?: boolean;
  "aria-label"?: string;
  className?: string;
};

// ── Dates, in local time, at midnight. No library: these are all it needs. ──
const day0 = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const month0 = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
function addMonths(d: Date, n: number) {
  const first = new Date(d.getFullYear(), d.getMonth() + n, 1);
  const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
  return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), last));
}
const cmp = (a: Date, b: Date) => day0(a).getTime() - day0(b).getTime();
const same = (a: Date | null | undefined, b: Date | null | undefined) => !!a && !!b && cmp(a, b) === 0;
const pad = (n: number) => String(n).padStart(2, "0");
const dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const monthKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
/** A known Sunday, for reading weekday names out of Intl. */
const SUNDAY = new Date(2026, 0, 4);

/**
 * A month of days to choose one from, or a stretch of them.
 *
 * Chosen days are ink tiles that land with a small settle when picked; the
 * days between the two ends of a range sit on a grey band that runs edge to
 * edge under the tiles, so the stretch reads as one continuous thing rather
 * than a row of separately selected squares. While the second end is still
 * open the band follows the pointer (or the keyboard) at half strength, so you
 * see the stay before you commit to it. Today is a dot under the number, not a
 * colour, so it still reads in greyscale and on top of an ink tile.
 *
 * Changing month slides the grid a short way in the direction of travel — later
 * months come in from the right — and fades, because the grid is the thing that
 * changed and the header and weekdays did not. It is short on purpose: you page
 * through months several times in a row, and a long slide would make every
 * press wait for the last. There are always six rows, so the height never jumps.
 *
 * It is an ARIA grid with one tab stop: the arrows move by day and week,
 * Home and End go to the ends of the week, Page Up and Down change month (with
 * Shift, year), and crossing a month edge turns the page under you. Names come
 * from `Intl.DateTimeFormat`, so the month, the weekdays and every day's spoken
 * label follow the locale without a date library.
 */
export function Calendar(props: CalendarProps) {
  const {
    month,
    defaultMonth,
    onMonthChange,
    weekStartsOn = 0,
    locale,
    min,
    max,
    isDisabled,
    showOutsideDays = true,
    autoFocus,
    "aria-label": ariaLabel,
    className = "",
  } = props;
  const reduce = useReducedMotion();
  const capId = useId();
  const wrap = useRef<HTMLDivElement>(null);

  // ── Selection, controlled or not ──
  const [innerDay, setInnerDay] = useState<Date | null>(() => (props.mode === "range" ? null : (props.defaultValue ?? null)));
  const [innerSpan, setInnerSpan] = useState<DateRange | null>(() => (props.mode === "range" ? (props.defaultValue ?? null) : null));
  const chosen = props.mode === "range" ? null : props.value !== undefined ? props.value : innerDay;
  const span = props.mode === "range" ? (props.value !== undefined ? props.value : innerSpan) : null;
  const anchor = chosen ?? span?.from ?? null;

  // ── The month on show ──
  const [innerMonth, setInnerMonth] = useState(() => month0(defaultMonth ?? anchor ?? new Date()));
  const view = month ? month0(month) : innerMonth;
  const [dir, setDir] = useState(1);
  const inView = (d: Date) => d.getFullYear() === view.getFullYear() && d.getMonth() === view.getMonth();
  const turn = (to: Date) => {
    const next = month0(to);
    const d = cmp(next, view);
    if (d === 0) return;
    setDir(d > 0 ? 1 : -1);
    if (!month) setInnerMonth(next);
    onMonthChange?.(next);
  };

  // Today is read after mount, so a server in another timezone cannot mark a different day.
  const [today, setToday] = useState<Date | null>(null);
  useEffect(() => setToday(day0(new Date())), []);

  // ── The roving tab stop ──
  const [focus, setFocus] = useState<Date>(() => day0(anchor ?? new Date()));
  const tabbable = inView(focus) ? focus : ([chosen, span?.from, span?.to, today].find((d) => d && inView(d)) ?? view);
  const wantFocus = useRef(!!autoFocus);
  useEffect(() => {
    if (!wantFocus.current) return;
    wantFocus.current = false;
    wrap.current?.querySelector<HTMLButtonElement>(`[data-month="${monthKey(view)}"] button[tabindex="0"]`)?.focus({ preventScroll: true });
  });

  const off = (d: Date) => (!!min && cmp(d, min) < 0) || (!!max && cmp(d, max) > 0) || !!isDisabled?.(d);
  const clamp = (d: Date) => (min && cmp(d, min) < 0 ? day0(min) : max && cmp(d, max) > 0 ? day0(max) : day0(d));

  const moveTo = (d: Date) => {
    const n = clamp(d);
    setFocus(n);
    if (!inView(n)) turn(n);
    wantFocus.current = true;
  };

  const page = (n: number) => {
    turn(addMonths(view, n));
    setFocus(clamp(addMonths(tabbable, n)));
  };

  // The tile that was just picked lands; every other chosen tile is simply there.
  const [landed, setLanded] = useState<string | null>(null);
  const choose = (d: Date) => {
    if (off(d)) return;
    setFocus(d);
    setLanded(dayKey(d));
    if (props.mode === "range") {
      const cur = span;
      const next: DateRange =
        !cur?.from || cur.to ? { from: d, to: null } : cmp(d, cur.from) < 0 ? { from: d, to: cur.from } : { from: cur.from, to: d };
      if (props.value === undefined) setInnerSpan(next);
      props.onChange?.(next);
    } else {
      if (props.value === undefined) setInnerDay(d);
      props.onChange?.(d);
    }
  };

  const onKey = (e: KeyboardEvent) => {
    const f = tabbable;
    const col = (f.getDay() - weekStartsOn + 7) % 7;
    const to: Record<string, () => Date> = {
      ArrowLeft: () => addDays(f, -1),
      ArrowRight: () => addDays(f, 1),
      ArrowUp: () => addDays(f, -7),
      ArrowDown: () => addDays(f, 7),
      Home: () => addDays(f, -col),
      End: () => addDays(f, 6 - col),
      PageUp: () => addMonths(f, e.shiftKey ? -12 : -1),
      PageDown: () => addMonths(f, e.shiftKey ? 12 : 1),
    };
    if (!to[e.key]) return;
    e.preventDefault();
    moveTo(to[e.key]());
  };

  // ── The range band, settled or previewed ──
  const [hover, setHover] = useState<Date | null>(null);
  const [inGrid, setInGrid] = useState(false);
  let lo: Date | null = null;
  let hi: Date | null = null;
  let preview = false;
  if (span?.from && span.to) {
    lo = span.from;
    hi = span.to;
  } else if (span?.from) {
    const end = hover ?? (inGrid ? tabbable : null);
    if (end) {
      preview = true;
      [lo, hi] = cmp(end, span.from) < 0 ? [end, span.from] : [span.from, end];
    }
  }

  const fmt = useMemo(
    () => ({
      caption: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
      spoken: new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }),
      num: new Intl.DateTimeFormat(locale, { day: "numeric" }),
      weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }),
    }),
    [locale]
  );

  const lead = (view.getDay() - weekStartsOn + 7) % 7;
  const start = addDays(view, -lead);
  const weeks = Array.from({ length: 6 }, (_, w) => Array.from({ length: 7 }, (_, c) => addDays(start, w * 7 + c)));
  const weekdays = Array.from({ length: 7 }, (_, c) => fmt.weekday.format(addDays(SUNDAY, (weekStartsOn + c) % 7)));

  const prevOff = !!min && cmp(addDays(view, -1), min) < 0;
  const nextOff = !!max && cmp(addMonths(view, 1), max) > 0;

  const slide: Variants = {
    enter: (d: number) => (reduce ? { opacity: 0 } : { x: d * 28, opacity: 0 }),
    center: {
      x: 0,
      opacity: 1,
      transition: reduce ? FLAT : { x: { duration: 0.26, ease: EASE_OUT_EXPO }, opacity: { duration: 0.18, ease: "easeOut" } },
    },
    exit: (d: number) => (reduce ? { opacity: 0, transition: CLOSE } : { x: d * -28, opacity: 0, transition: CLOSE }),
  };

  return (
    <LazyMotion features={domAnimation} strict>
      <div role="group" aria-label={ariaLabel} className={`inline-flex w-max flex-col ${className}`}>
        <div className="flex h-9 items-center justify-between gap-2 pl-2.5">
          <div id={capId} aria-live="polite" className="text-[13px] font-medium text-gray-1000">
            {fmt.caption.format(view)}
          </div>
          <div className="flex items-center gap-0.5">
            <Button variant="ghost" size="icon-sm" aria-label="Previous month" disabled={prevOff} onClick={() => page(-1)}>
              <ChevronDownIcon className="h-3.5 w-3.5 rotate-90" />
            </Button>
            <Button variant="ghost" size="icon-sm" aria-label="Next month" disabled={nextOff} onClick={() => page(1)}>
              <ChevronDownIcon className="h-3.5 w-3.5 -rotate-90" />
            </Button>
          </div>
        </div>

        {/* Every day's button carries its full spoken date, so these are for the eye only. */}
        <div aria-hidden className="grid grid-cols-7">
          {weekdays.map((w, c) => (
            <span key={c} className="flex h-8 w-9 items-center justify-center text-[11px] text-faint">
              {w}
            </span>
          ))}
        </div>

        {/* Padded out and pulled back, so the clip that hides the slide does not also clip a focus ring. */}
        <div ref={wrap} className="relative -mx-1.5 -my-1 overflow-hidden px-1.5 py-1">
          <AnimatePresence mode="popLayout" initial={false} custom={dir}>
            <m.table
              key={monthKey(view)}
              data-month={monthKey(view)}
              custom={dir}
              variants={slide}
              initial="enter"
              animate="center"
              exit="exit"
              role="grid"
              aria-labelledby={capId}
              onKeyDown={onKey}
              onMouseLeave={() => setHover(null)}
              onFocus={() => setInGrid(true)}
              onBlur={(e) => {
                if (!e.currentTarget.contains(e.relatedTarget as Node)) setInGrid(false);
              }}
              className="border-collapse"
            >
              <tbody>
                {weeks.map((week, w) => (
                  <tr key={w}>
                    {week.map((d, c) => {
                      const outside = !inView(d);
                      const key = dayKey(d);
                      const isEnd = props.mode === "range" ? same(d, span?.from) || same(d, span?.to) : same(d, chosen);
                      const tile = !outside && isEnd;
                      const banded = !!lo && !!hi && !same(lo, hi) && cmp(d, lo) >= 0 && cmp(d, hi) <= 0 && (!outside || showOutsideDays);
                      const place = same(d, lo) ? "left-1/2 right-0" : same(d, hi) ? "left-0 right-1/2" : "inset-x-0";
                      const edge = `${c === 0 ? "rounded-l-[8px]" : ""} ${c === 6 ? "rounded-r-[8px]" : ""}`;
                      const dis = off(d);
                      const isToday = same(d, today);
                      return (
                        <td key={c} aria-selected={outside ? undefined : isEnd || banded} className="relative h-9 w-9 p-0">
                          {banded && (
                            <span aria-hidden className={`absolute inset-y-0 ${place} ${edge} ${preview ? "bg-gray-alpha-100" : "bg-gray-alpha-200"}`} />
                          )}
                          {outside ? (
                            showOutsideDays && (
                              <span aria-hidden className="relative flex h-9 w-9 items-center justify-center text-[13px] tabular-nums text-faint">
                                {fmt.num.format(d)}
                              </span>
                            )
                          ) : (
                            <button
                              type="button"
                              data-day={key}
                              tabIndex={same(d, tabbable) ? 0 : -1}
                              aria-label={fmt.spoken.format(d)}
                              aria-current={isToday ? "date" : undefined}
                              aria-disabled={dis || undefined}
                              onClick={() => choose(d)}
                              onFocus={() => !same(d, focus) && setFocus(d)}
                              onMouseEnter={() => setHover(d)}
                              className={`relative flex h-9 w-9 items-center justify-center rounded-[8px] text-[13px] tabular-nums transition-[background-color,transform] duration-150 ease-geist motion-reduce:transition-none ${
                                tile
                                  ? "font-medium text-background-100 active:scale-[0.97]"
                                  : dis
                                    ? "cursor-not-allowed text-faint line-through decoration-gray-alpha-600"
                                    : "text-gray-1000 hover:bg-gray-alpha-200 active:scale-[0.97]"
                              }`}
                            >
                              {tile && (
                                <m.span
                                  aria-hidden
                                  className="absolute inset-0 rounded-[8px] bg-ink"
                                  initial={landed === key ? (reduce ? { opacity: 0 } : { scale: 0.85 }) : false}
                                  animate={{ scale: 1, opacity: 1 }}
                                  transition={reduce ? FLAT : LIFT}
                                />
                              )}
                              <span className="relative">{fmt.num.format(d)}</span>
                              {isToday && <span aria-hidden className="absolute bottom-1 left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full bg-current" />}
                            </button>
                          )}
                        </td>
                      );
                    })}
                  </tr>
                ))}
              </tbody>
            </m.table>
          </AnimatePresence>
        </div>
      </div>
    </LazyMotion>
  );
}
packages/ui/src/components/actions/Button.tsx91 lines
"use client";

import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Spinner } from "./Spinner.js";

export type ButtonVariant = "primary" | "secondary" | "ghost" | "glass";
export type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm";

export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  /**
   * `primary` is the one ink fill on a screen. `secondary` is a hairline on the
   * page. `ghost` has no edge until you point at it. `glass` floats over content.
   */
  variant?: ButtonVariant;
  size?: ButtonSize;
  /** Swap the label's leading mark for a spinner and refuse presses, keeping the width. */
  loading?: boolean;
  /** A mark before the label. */
  icon?: ReactNode;
  /** A mark after the label — an arrow, a shortcut. */
  trailing?: ReactNode;
};

/** The class strings, exported so a link or a menu row can wear a button without being one. */
export const buttonVariants: Record<ButtonVariant, string> = {
  primary: "bg-ink text-background-100 hover:bg-[color-mix(in_srgb,var(--color-ink)_86%,var(--color-background-100))]",
  secondary:
    "border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 hover:border-gray-alpha-600 hover:bg-gray-100",
  ghost: "text-muted hover:bg-gray-alpha-200 hover:text-gray-1000",
  glass: "glass-nav dock-shadow border-[0.5px] border-gray-alpha-400 text-gray-1000 hover:bg-[var(--glass-menu-bg)]",
};

export const buttonSizes: Record<ButtonSize, string> = {
  sm: "h-7 gap-1.5 rounded-[8px] px-2.5 text-[12px]",
  md: "h-9 gap-2 rounded-[10px] px-3.5 text-[13px]",
  lg: "h-10 gap-2 rounded-[12px] px-4 text-[14px]",
  icon: "h-9 w-9 rounded-[10px]",
  "icon-sm": "h-7 w-7 rounded-[8px]",
};

/** Everything a button shares whatever it wears: the press, the transition, the disabled dim. */
export const buttonBase =
  "inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium leading-none transition-[background-color,border-color,color,transform,opacity] duration-150 ease-geist active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100";

export function buttonClass(variant: ButtonVariant = "secondary", size: ButtonSize = "md", extra = "") {
  return `${buttonBase} ${buttonVariants[variant]} ${buttonSizes[size]} ${extra}`;
}

/**
 * A button, in four materials and five sizes.
 *
 * Only `primary` is filled, and a screen should have one of them: the fill is
 * how the eye finds the thing to do next, and two fills make the reader choose
 * between them before they have read either. Everything else is a hairline or
 * nothing at all. There is deliberately no red "destructive" variant — the
 * palette is monochrome, and what makes a destructive button safe is the
 * words on it and the confirmation in front of it (see AlertDialog), not a
 * colour.
 *
 * The press is a 3% shrink on the house curve: enough to feel the button
 * take the click, too small to read as movement. While `loading`, the leading
 * mark becomes a spinner in the same box, so the label does not jump and the
 * button does not change width under the cursor.
 */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "secondary", size = "md", loading = false, icon, trailing, className = "", children, disabled, type = "button", ...rest },
  ref
) {
  const lead = loading ? <Spinner size={size === "sm" || size === "icon-sm" ? 12 : 14} /> : icon;
  const iconOnly = size === "icon" || size === "icon-sm";
  return (
    <button
      ref={ref}
      type={type}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      className={buttonClass(variant, size, className)}
      {...rest}
    >
      {iconOnly ? (
        (lead ?? children)
      ) : (
        <>
          {lead && <span className="flex shrink-0 items-center">{lead}</span>}
          {children}
          {trailing && <span className="-mr-0.5 flex shrink-0 items-center opacity-70">{trailing}</span>}
        </>
      )}
    </button>
  );
});