Skip to content

EventCalendar

Shared Components

Reusable month, week, and agenda scheduling with local civil-date events.

Month with event overflowSeven-day weekMonthly agendaInclusive multi-day eventCreate/inspect/delete dialogs
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Use this scheduler alongside the existing Calendar date-selection primitive. Own the visible selected YYYY-MM-DD date and events array. Event endDate is inclusive and optional; time is a display-only local HH:mm label, not a timezone conversion. Month view includes adjacent dates and overflow controls; mobile shows compact counts with the selected day’s full event list below; Week lists every day; Agenda lists event days in the visible month. Add and select actions are callbacks for your application. The example implements local create, inspect, and delete dialogs. This is not a recurring-event, drag-and-drop, or timezone scheduling engine.

Import
import {
  EventCalendar,
  type ScheduleEvent,
} from "@/components/ui/event-calendar";

Shipped as editable source in src/components/ui/event-calendar.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/event-calendar.tsx
"use client";
import { useId, useState } from "react";
import {
  addDays,
  addMonths,
  format,
  isValid,
  parseISO,
  startOfMonth,
  startOfWeek,
} from "date-fns";
import { ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ScheduleEvent {
  id: string;
  title: string;
  date: string;
  endDate?: string;
  time?: string;
  tone?: "primary" | "success" | "warning";
}
export type CalendarView = "month" | "week" | "agenda";
export interface EventCalendarProps {
  label: string;
  events: ScheduleEvent[];
  date: string;
  onDateChange: (date: string) => void;
  view?: CalendarView;
  onViewChange?: (view: CalendarView) => void;
  onEventSelect?: (event: ScheduleEvent) => void;
  onCreate?: (date: string) => void;
  weekStartsOn?: 0 | 1;
  today?: string;
  className?: string;
}
export function isCalendarDate(value: string) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
  const parsed = parseISO(value);
  return isValid(parsed) && format(parsed, "yyyy-MM-dd") === value;
}
export function eventsForDate(events: ScheduleEvent[], date: string) {
  return events
    .filter(
      (event) =>
        isCalendarDate(event.date) &&
        (!event.endDate || isCalendarDate(event.endDate)) &&
        event.date <= date &&
        (event.endDate ?? event.date) >= date,
    )
    .sort(
      (a, b) =>
        (a.time ?? "").localeCompare(b.time ?? "") ||
        a.title.localeCompare(b.title),
    );
}
export function EventCalendar({
  label,
  events,
  date,
  onDateChange,
  view: controlledView,
  onViewChange,
  onEventSelect,
  onCreate,
  weekStartsOn = 1,
  today = format(new Date(), "yyyy-MM-dd"),
  className,
}: EventCalendarProps) {
  const id = useId();
  const [internalView, setInternalView] = useState<CalendarView>("month");
  const view = controlledView ?? internalView;
  const selected = isCalendarDate(date)
    ? parseISO(date)
    : parseISO("2026-01-01");
  const start = startOfWeek(
    view === "week" ? selected : startOfMonth(selected),
    { weekStartsOn },
  );
  const days = Array.from({ length: view === "week" ? 7 : 42 }, (_, index) =>
    addDays(start, index),
  );
  const changeView = (next: CalendarView) => {
    setInternalView(next);
    onViewChange?.(next);
  };
  const navigate = (amount: number) =>
    onDateChange(
      format(
        view === "week"
          ? addDays(selected, amount * 7)
          : addMonths(selected, amount),
        "yyyy-MM-dd",
      ),
    );
  const eventButton = (event: ScheduleEvent, day: string, compact = false) => {
    const content = (
      <>
        <span className="font-medium">
          {event.time && `${event.time} `}
          {event.title}
        </span>
        {event.endDate && event.endDate !== event.date && !compact && (
          <span className="block text-xs opacity-80">
            {event.date} – {event.endDate}
          </span>
        )}
      </>
    );
    const style = cn(
      "block w-full min-w-0 rounded-md px-2 py-1 text-start text-xs",
      compact ? "truncate" : "py-2",
      event.tone === "success"
        ? "bg-success/10 text-foreground"
        : event.tone === "warning"
          ? "bg-warning/15 text-foreground"
          : "bg-primary/10 text-foreground",
    );
    return onEventSelect ? (
      <button
        key={`${event.id}-${day}`}
        type="button"
        onClick={() => onEventSelect(event)}
        aria-label={`${event.time ? `${event.time} ` : ""}${event.title}${event.endDate && event.endDate !== event.date && !compact ? ` ${event.date} – ${event.endDate}` : ""}, ${day}`}
        className={cn(
          style,
          "outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
        )}
      >
        {content}
      </button>
    ) : (
      <div key={`${event.id}-${day}`} className={style}>
        {content}
      </div>
    );
  };
  const agendaDays =
    view === "week"
      ? days
      : Array.from({ length: 31 }, (_, index) =>
          addDays(startOfMonth(selected), index),
        ).filter((day) => day.getMonth() === selected.getMonth());
  return (
    <section aria-label={label} className={cn("min-w-0 space-y-4", className)}>
      <div className="flex flex-wrap items-center justify-between gap-3">
        <div>
          <h3 id={id} className="text-lg font-semibold tracking-tight">
            {format(selected, "MMMM yyyy")}
          </h3>
          <p className="text-xs text-muted-foreground">{label}</p>
        </div>
        <div className="flex flex-wrap gap-1">
          <Button
            type="button"
            variant="outline"
            size="icon-sm"
            aria-label="Previous calendar period"
            onClick={() => navigate(-1)}
          >
            <ChevronLeft className="size-4 rtl:rotate-180" />
          </Button>
          <Button
            type="button"
            variant="outline"
            size="sm"
            onClick={() => onDateChange(today)}
          >
            Today
          </Button>
          <Button
            type="button"
            variant="outline"
            size="icon-sm"
            aria-label="Next calendar period"
            onClick={() => navigate(1)}
          >
            <ChevronRight className="size-4 rtl:rotate-180" />
          </Button>
          {onCreate && (
            <Button
              type="button"
              size="sm"
              onClick={() => onCreate(format(selected, "yyyy-MM-dd"))}
            >
              <Plus className="size-4" />
              Add event
            </Button>
          )}
        </div>
      </div>
      <div
        role="group"
        aria-label="Calendar view"
        className="inline-flex rounded-lg border bg-muted/20 p-1"
      >
        {(["month", "week", "agenda"] as const).map((option) => (
          <button
            key={option}
            type="button"
            aria-pressed={view === option}
            onClick={() => changeView(option)}
            className={cn(
              "rounded-md px-3 py-1.5 text-xs capitalize outline-none focus-visible:ring-2 focus-visible:ring-ring",
              view === option
                ? "bg-background font-medium shadow-xs"
                : "text-muted-foreground",
            )}
          >
            {option}
          </button>
        ))}
      </div>
      {view === "month" ? (
        <div className="overflow-hidden rounded-xl border">
          <table
            aria-labelledby={id}
            className="w-full table-fixed border-collapse"
          >
            <thead>
              <tr>
                {days.slice(0, 7).map((day) => (
                  <th
                    scope="col"
                    key={day.toISOString()}
                    className="border-b bg-muted/30 py-2 text-xs font-medium text-muted-foreground"
                  >
                    {format(day, "EEE")}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {Array.from({ length: 6 }, (_, row) => (
                <tr key={row}>
                  {days.slice(row * 7, row * 7 + 7).map((day) => {
                    const key = format(day, "yyyy-MM-dd");
                    const dayEvents = eventsForDate(events, key);
                    return (
                      <td
                        key={key}
                        className={cn(
                          "h-16 border-e border-b p-1 align-top last:border-e-0 sm:h-28",
                          day.getMonth() !== selected.getMonth() &&
                            "bg-muted/20 text-muted-foreground",
                          key === date && "bg-primary/5",
                        )}
                      >
                        <button
                          type="button"
                          aria-label={`Select ${format(day, "EEEE, MMMM d, yyyy")}`}
                          aria-pressed={key === date}
                          aria-current={key === today ? "date" : undefined}
                          onClick={() => onDateChange(key)}
                          className={cn(
                            "mb-1 flex size-7 items-center justify-center rounded-full text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring",
                            key === today
                              ? "bg-primary text-primary-foreground"
                              : "hover:bg-muted",
                            key === date && "ring-1 ring-primary",
                          )}
                        >
                          {format(day, "d")}
                        </button>
                        {dayEvents.length > 0 && (
                          <button
                            type="button"
                            className="flex w-full items-center justify-center gap-1 rounded py-1 text-[10px] text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring sm:hidden"
                            aria-label={`${dayEvents.length} ${dayEvents.length === 1 ? "event" : "events"} on ${key}`}
                            onClick={() => onDateChange(key)}
                          >
                            <span
                              aria-hidden
                              className="size-1 rounded-full bg-primary"
                            />
                            {dayEvents.length}
                          </button>
                        )}
                        <div className="hidden space-y-1 sm:block">
                          {dayEvents
                            .slice(0, 2)
                            .map((event) => eventButton(event, key, true))}
                          {dayEvents.length > 2 && (
                            <button
                              type="button"
                              aria-label={`+${dayEvents.length - 2} more. View all ${dayEvents.length} events on ${key}`}
                              className="w-full rounded text-start text-[10px] text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring"
                              onClick={() => {
                                onDateChange(key);
                                changeView("week");
                              }}
                            >
                              +{dayEvents.length - 2} more
                            </button>
                          )}
                        </div>
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        <div className="divide-y rounded-xl border">
          {agendaDays
            .filter(
              (day) =>
                view === "week" ||
                eventsForDate(events, format(day, "yyyy-MM-dd")).length,
            )
            .map((day) => {
              const key = format(day, "yyyy-MM-dd");
              const items = eventsForDate(events, key);
              return (
                <div
                  key={key}
                  className="grid gap-3 p-4 sm:grid-cols-[110px_1fr]"
                >
                  <button
                    type="button"
                    aria-pressed={key === date}
                    className="self-start rounded-md text-start text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
                    onClick={() => onDateChange(key)}
                  >
                    <span className="font-medium">
                      {format(day, "EEE, MMM d")}
                    </span>
                  </button>
                  <div className="min-w-0 space-y-2">
                    {items.length ? (
                      items.map((event) => eventButton(event, key))
                    ) : (
                      <p className="py-2 text-xs text-muted-foreground">
                        No events scheduled.
                      </p>
                    )}
                  </div>
                </div>
              );
            })}
          {view === "agenda" &&
            !agendaDays.some(
              (day) => eventsForDate(events, format(day, "yyyy-MM-dd")).length,
            ) && (
              <p className="p-6 text-sm text-muted-foreground">
                No events this month.
              </p>
            )}
        </div>
      )}
      {view === "month" && (
        <div className="space-y-3 rounded-xl border p-4 sm:hidden">
          <h4 className="text-sm font-semibold">
            {format(selected, "EEEE, MMMM d")}
          </h4>
          {eventsForDate(events, format(selected, "yyyy-MM-dd")).length ? (
            eventsForDate(events, format(selected, "yyyy-MM-dd")).map((event) =>
              eventButton(event, format(selected, "yyyy-MM-dd")),
            )
          ) : (
            <p className="text-xs text-muted-foreground">
              No events scheduled.
            </p>
          )}
        </div>
      )}
      <p role="status" className="text-xs text-muted-foreground">
        Selected {format(selected, "MMMM d, yyyy")} ·{" "}
        {eventsForDate(events, format(selected, "yyyy-MM-dd")).length} events
      </p>
    </section>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

EventCalendar props
PropType / DefaultDescription
labelstring

Default: required

Schedule name.
eventsScheduleEvent[]

Default: required

Unique id, title, valid date, optional inclusive endDate, time label, and tone.
datestring

Default: required

Controlled selected civil date; invalid input displays January 1, 2026 until corrected.
onDateChange(date: string) => void

Default: required

Receive selection or period navigation.
view / onViewChangeCalendarView / callback

Default: month / —

Optional controlled month, week, or agenda view.
onEventSelect(event: ScheduleEvent) => void

Default: —

Enable event buttons for application details or editing.
onCreate(date: string) => void

Default: —

Show Add event and receive the selected date.
weekStartsOn0 | 1

Default: 1

Sunday or Monday week start.
todaystring

Default: current local date

Override the today marker and Today button target, useful for deterministic previews.
classNamestring

Default: —

Extend the container with semantic theme classes.

Build with these too