EventCalendar
Shared ComponentsReusable month, week, and agenda scheduling with local civil-date events.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import {
EventCalendar,
type ScheduleEvent,
} from "@/components/ui/event-calendar";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@dashboardpack/core/components/ui/dialog";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { Button } from "@dashboardpack/core/components/ui/button";
const initial: ScheduleEvent[] = [
{
id: "planning",
title: "Sprint planning",
date: "2026-10-05",
time: "09:30",
},
{
id: "workshop",
title: "Design workshop",
date: "2026-10-12",
endDate: "2026-10-14",
tone: "success",
},
{
id: "review",
title: "Release review",
date: "2026-10-27",
time: "14:00",
tone: "warning",
},
{ id: "sync", title: "Team sync", date: "2026-10-27", time: "10:00" },
{
id: "launch",
title: "Apex launch",
date: "2026-10-27",
time: "16:00",
tone: "success",
},
];
export default function EventCalendarExample() {
const [date, setDate] = useState("2026-10-27");
const [events, setEvents] = useState(initial);
const [selected, setSelected] = useState<ScheduleEvent | null>(null);
const [create, setCreate] = useState(false);
const [title, setTitle] = useState("");
const [eventDate, setEventDate] = useState(date);
return (
<div className="w-full max-w-3xl">
<EventCalendar
label="Studio schedule"
date={date}
onDateChange={setDate}
events={events}
today="2026-10-27"
onEventSelect={setSelected}
onCreate={(next) => {
setEventDate(next);
setTitle("");
setCreate(true);
}}
/>
<Dialog
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{selected?.title}</DialogTitle>
<DialogDescription>
{selected?.date}
{selected?.endDate && ` through ${selected.endDate}`}
{selected?.time && ` at ${selected.time}`}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Preview event details. Changes in this example stay in memory.
</p>
<Button
type="button"
variant="destructive"
onClick={() => {
setEvents(events.filter((event) => event.id !== selected?.id));
setSelected(null);
}}
>
Delete event
</Button>
</DialogContent>
</Dialog>
<Dialog open={create} onOpenChange={setCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add calendar event</DialogTitle>
<DialogDescription>
Create a local preview event for the selected date.
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!title.trim()) return;
setEvents([
...events,
{
id: crypto.randomUUID(),
title: title.trim(),
date: eventDate,
},
]);
setDate(eventDate);
setCreate(false);
}}
>
<div className="space-y-2">
<Label htmlFor="event-title">Event title</Label>
<Input
id="event-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">Event date</Label>
<Input
id="event-date"
required
type="date"
value={eventDate}
onChange={(event) => setEventDate(event.target.value)}
/>
</div>
<Button type="submit">Create event</Button>
</form>
</DialogContent>
</Dialog>
</div>
);
}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 {
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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Schedule name. |
| events | ScheduleEvent[]Default: required | Unique id, title, valid date, optional inclusive endDate, time label, and tone. |
| date | stringDefault: required | Controlled selected civil date; invalid input displays January 1, 2026 until corrected. |
| onDateChange | (date: string) => voidDefault: required | Receive selection or period navigation. |
| view / onViewChange | CalendarView / callbackDefault: month / — | Optional controlled month, week, or agenda view. |
| onEventSelect | (event: ScheduleEvent) => voidDefault: — | Enable event buttons for application details or editing. |
| onCreate | (date: string) => voidDefault: — | Show Add event and receive the selected date. |
| weekStartsOn | 0 | 1Default: 1 | Sunday or Monday week start. |
| today | stringDefault: current local date | Override the today marker and Today button target, useful for deterministic previews. |
| className | stringDefault: — | Extend the container with semantic theme classes. |