Calendar
A month to pick a day or a stay from, that slides a short way when you turn the page.
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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| mode"single" | "range" | "single" | "range"= "single" | One day, or a `{ from, to }` stretch. `to` is null while the second end is open. |
| value / onChangeDate | null or DateRange | null / (value) => void | Date | null or DateRange | null / (value) => void | Controlled, or `defaultValue`. |
| month / onMonthChangeDate / (month) => void | Date / (month) => void | The month on show, if you want to own it. Otherwise it opens on the chosen day. |
| weekStartsOn0–6 | 0–6= 0 | 0 is Sunday. 1 for most of Europe. |
| localestring | string | For month and day names. Pass one when rendering on the server, so both sides agree. |
| min / max / isDisabledDate / Date / (date) => boolean | Date / Date / (date) => boolean | Days outside or disabled are struck through and refuse a click, but the keyboard still passes over them. |
| autoFocusboolean | boolean | Focus 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.
"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>
);
}
"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>
);
});