A sidebar-sized glanceable month with event dots, sliding prev/next paging, arrow-key navigation, and click-to-emit dates.
npx shadcn@latest add @paragon/mini-monthAlso installs: calendar
"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>
);
}