Event Calendar
Data Display

Event Calendar

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.

Install

npx shadcn@latest add @paragon/event-calendar

Also installs: calendar, popover

event-calendar.tsx

"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>
  );
}