A month grid with toned event chips — up to three per day, the rest folded into a +N more popover — hover-linked day highlighting, and sliding month navigation.
npx shadcn@latest add @paragon/event-calendarAlso installs: calendar, popover
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
addMonths,
dateKey,
MONTH_NAMES,
monthGridDates,
sameDay,
startOfDay,
} from "@/registry/paragon/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";
export type EventTone = "default" | "primary" | "success" | "warning" | "destructive";
export interface CalendarEvent {
id: string;
date: Date;
title: string;
/** Short time label, e.g. "9:30 AM". */
time?: string;
tone?: EventTone;
}
const TONE_CHIP: Record<EventTone, string> = {
default: "bg-secondary text-secondary-foreground",
primary: "bg-primary/10 text-foreground",
success: "bg-success/15 text-success dark:bg-success/20",
warning: "bg-warning/20 text-warning-foreground dark:text-warning",
destructive: "bg-destructive/12 text-destructive",
};
const TONE_DOT: Record<EventTone, string> = {
default: "bg-muted-foreground",
primary: "bg-primary",
success: "bg-success",
warning: "bg-warning",
destructive: "bg-destructive",
};
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const monthKeyOf = (d: Date) => `${d.getFullYear()}-${d.getMonth()}`;
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];
export interface EventCalendarProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
events?: CalendarEvent[];
month?: Date;
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
onEventSelect?: (event: CalendarEvent) => void;
onDateSelect?: (date: Date) => void;
/** Reference "today". Pass a fixed date for deterministic renders. */
now?: Date;
weekStartsOn?: 0 | 1;
/** Visible chips per day before collapsing into "+N more". */
maxVisibleEvents?: number;
/** Disables the month slide transition. */
static?: boolean;
}
function EventChip({
event,
dimmed,
onSelect,
}: {
event: CalendarEvent;
dimmed: boolean;
onSelect?: (event: CalendarEvent) => void;
}) {
const tone = event.tone ?? "default";
return (
<button
type="button"
onClick={() => onSelect?.(event)}
aria-label={`${event.title}${event.time ? `, ${event.time}` : ""}`}
className={cn(
"flex h-5 w-full items-center gap-1 rounded px-1.5 text-left text-[11px] leading-none font-medium outline-none",
"transition-[opacity,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:not-disabled:scale-[0.97] hover:opacity-80",
TONE_CHIP[tone],
dimmed && "opacity-50",
)}
>
<span
aria-hidden
className={cn("size-1.5 shrink-0 rounded-full", TONE_DOT[tone])}
/>
{event.time && (
<span className="shrink-0 opacity-70 tabular-nums">{event.time}</span>
)}
<span className="min-w-0 flex-1 truncate">{event.title}</span>
</button>
);
}
/**
* A month grid with event chips: up to three per day, the rest folded
* into a "+N more" popover; hovering anywhere in a day links a soft
* highlight across the whole cell. Gridlines come from a 1px gap over
* the border token, so alignment is exact at any width.
*/
export function EventCalendar({
events = [],
month: monthProp,
defaultMonth,
onMonthChange,
onEventSelect,
onDateSelect,
now,
weekStartsOn = 1,
maxVisibleEvents = 3,
static: isStatic = false,
className,
"aria-label": ariaLabel = "Event calendar",
...props
}: EventCalendarProps) {
const reduced = useReducedMotion() ?? false;
const [fallbackToday] = React.useState(() => startOfDay(new Date()));
const today = now ? startOfDay(now) : fallbackToday;
const [monthState, setMonthState] = React.useState<Date>(() => {
const base = defaultMonth ?? today;
return new Date(base.getFullYear(), base.getMonth(), 1);
});
const visibleMonth = monthProp
? new Date(monthProp.getFullYear(), monthProp.getMonth(), 1)
: monthState;
const mKey = monthKeyOf(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 changeMonth = (next: Date) => {
const start = new Date(next.getFullYear(), next.getMonth(), 1);
if (monthKeyOf(start) === mKey) return;
setMonthState(start);
onMonthChange?.(start);
};
const byDay = React.useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const ev of events) {
const k = dateKey(ev.date);
const list = map.get(k) ?? [];
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 animate = !isStatic && !reduced;
const label = `${MONTH_NAMES[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;
return (
<div className={cn("w-full min-w-0 select-none", className)} {...props}>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="relative min-w-0 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 * 14 : 0, opacity: animate ? 0 : 1 }}
animate={{ x: 0, opacity: 1 }}
exit={{
x: animate ? slide.dir * -14 : 0,
opacity: 0,
transition: { duration: animate ? 0.12 : 0, ease: EASE_EXIT },
}}
transition={{ duration: 0.18, ease: EASE_OUT }}
className="block truncate text-base font-semibold text-foreground"
aria-hidden
>
{label}
</motion.span>
</AnimatePresence>
</span>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => changeMonth(today)}
className="pressable h-7 rounded-md px-2.5 text-xs font-medium 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"
>
Today
</button>
{([-1, 1] as const).map((dir) => (
<button
key={dir}
type="button"
aria-label={dir === -1 ? "Previous month" : "Next month"}
onClick={() => changeMonth(addMonths(visibleMonth, dir))}
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"
>
{dir === -1 ? (
<ChevronLeft className="size-4" aria-hidden />
) : (
<ChevronRight className="size-4" aria-hidden />
)}
</button>
))}
</div>
</div>
<div
className="overflow-hidden rounded-xl shadow-border"
aria-label={`${ariaLabel}, ${label}`}
>
<div className="grid grid-cols-7 gap-px border-b border-border bg-card">
{weekdays.map((w) => (
<div
key={w}
className="bg-card px-2 py-1.5 text-right text-[11px] font-medium text-muted-foreground"
>
{WEEKDAY_LABELS[w]}
</div>
))}
</div>
<div className="relative overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={mKey}
initial={{ x: animate ? slide.dir * 20 : 0, opacity: animate ? 0 : 1 }}
animate={{ x: 0, opacity: 1 }}
exit={{
x: animate ? slide.dir * -20 : 0,
opacity: 0,
transition: { duration: animate ? 0.15 : 0, ease: EASE_EXIT },
}}
transition={{ duration: 0.2, ease: EASE_OUT }}
className="grid grid-cols-7 gap-px bg-border"
>
{days.map((d) => {
const key = dateKey(d);
const outside = monthKeyOf(d) !== mKey;
const isToday = sameDay(d, today);
const dayEvents = byDay.get(key) ?? [];
const visible = dayEvents.slice(0, maxVisibleEvents);
const overflow = dayEvents.length - visible.length;
return (
<div
key={key}
className={cn(
"group/day relative flex min-h-24 flex-col gap-1 bg-card p-1.5 transition-colors duration-150 ease-out",
"hover:bg-accent/40",
outside && "bg-card/60",
)}
>
<div className="flex justify-end">
<button
type="button"
onClick={() => onDateSelect?.(d)}
aria-label={`${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}${dayEvents.length ? `, ${dayEvents.length} ${dayEvents.length === 1 ? "event" : "events"}` : ""}`}
className={cn(
"flex size-6 items-center justify-center rounded-full text-xs tabular-nums outline-none",
"transition-[background-color,color,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:not-disabled:scale-[0.95]",
isToday
? "bg-primary font-semibold text-primary-foreground"
: outside
? "text-muted-foreground/50 hover:bg-accent"
: "text-foreground hover:bg-accent",
)}
>
{d.getDate()}
</button>
</div>
<div className={cn("flex flex-col gap-0.5", outside && "opacity-60")}>
{visible.map((ev) => (
<EventChip
key={ev.id}
event={ev}
dimmed={false}
onSelect={onEventSelect}
/>
))}
{overflow > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-5 w-full items-center rounded px-1.5 text-left text-[11px] font-medium 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"
>
+{overflow} more
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={4} className="w-64 p-2">
<p className="px-1.5 pt-0.5 pb-1.5 text-xs font-medium text-muted-foreground">
{MONTH_NAMES[d.getMonth()]} {d.getDate()} ·{" "}
{dayEvents.length} events
</p>
<div className="flex flex-col gap-1">
{dayEvents.map((ev) => (
<EventChip
key={ev.id}
event={ev}
dimmed={false}
onSelect={onEventSelect}
/>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
);
})}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
);
}