ZaidanHomeDocsComponentsBlocksChartsTypesetCreate

Command Palette

Search for a command to run...

GitHub
New
Sections
  • Introduction
  • Components
  • Blocks
  • Installation
  • Customization
  • Dark Mode
  • Typeset
  • Zaidan Skills
  • FAQ
  • Roadmap
  • Changelog
Installation
  • Astro
  • Manual
  • Solid Start
  • TanStack Router
  • TanStack Start
  • Vite
Blocks
  • Data Grid
  • Event Calendar
  • Filters
  • Gantt
  • Image Crop
  • Kanban
  • Message Scroller
  • Questionnaire
  • Sortable
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Marker
  • Menubar
  • Message
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip

Event Calendar

August 2026
SunS
MonM
TueT
WedW
ThuT
FriF
SatS
26
27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
1
import { addDays, addMinutes, type Locale, setHours, startOfDay, startOfWeek } from "date-fns";
2
import { ar, de, es, fr, ja } from "date-fns/locale";
3
import { Plus, SlidersHorizontal } from "lucide-solid";
4
import { createSignal, Show } from "solid-js";
5
import {
6
type CalendarEvent,
7
type CalendarView,
8
EventCalendar,
9
type EventCalendarApi,
10
EventCalendarContent,
11
EventCalendarDatePicker,
12
type EventCalendarI18nOverrides,
13
type EventCalendarInteractions,
14
EventCalendarNav,
15
type EventCalendarOccurrence,
16
type EventCalendarRenderEventProps,
17
type EventCalendarResource,
18
EventCalendarToolbar,
19
type EventCalendarViewSettings,
20
} from "@/registry/kobalte/blocks/event-calendar";
21
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
22
import { Button } from "~/components/ui/button";
23
import { Card, CardContent } from "~/components/ui/card";
24
import { Label } from "~/components/ui/label";
25
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
26
import {
27
Select,
28
SelectContent,
29
SelectItem,
30
SelectTrigger,
31
SelectValue,
32
} from "~/components/ui/select";
33
import { Switch } from "~/components/ui/switch";
34
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
35
36
/** Team members - passing resources unlocks the resource day view, so the
37
* view switcher offers every view the calendar ships. */
38
const TEAM: EventCalendarResource[] = [
39
{ id: "alex", title: "Alex", color: "var(--color-blue-500)" },
40
{ id: "mia", title: "Mia", color: "var(--color-violet-500)" },
41
{ id: "sam", title: "Sam", color: "var(--color-emerald-500)" },
42
];
43
44
const INITIALS: Record<string, string> = { alex: "AL", mia: "MJ", sam: "SP" };
45
46
/** Demo events: a balanced current week (timed, multi-day, all-day, two
47
* custom-rendered chips) plus a light scatter in the nearby weeks so the
48
* month view reads naturally without crowding any cell. */
49
function buildEvents(anchor: Date): CalendarEvent[] {
50
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 });
51
const at = (dayOffset: number, hour: number, minute = 0) =>
52
addMinutes(setHours(addDays(week, dayOffset), hour), minute);
53
const day = (dayOffset: number) => addDays(week, dayOffset);
54
55
return [
56
{
57
id: "team-sync",
58
title: "Team sync",
59
start: at(1, 9, 0),
60
end: at(1, 9, 30),
61
resourceId: "alex",
62
// A weekly series: recurrence accepts a raw RRULE line or the
63
// structured EventCalendarRecurrenceRule shape.
64
recurrence: "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8",
65
},
66
{
67
id: "design-review",
68
title: "Design review",
69
start: at(2, 11, 0),
70
end: at(2, 12, 0),
71
resourceId: "mia",
72
color: "var(--color-violet-500)",
73
},
74
{
75
id: "product-demo",
76
title: "Product demo",
77
start: at(3, 15, 0),
78
end: at(3, 16, 0),
79
resourceId: "sam",
80
color: "var(--color-emerald-500)",
81
},
82
{
83
id: "roadmap-planning",
84
title: "Roadmap planning",
85
start: at(4, 10, 0),
86
end: at(4, 11, 30),
87
resourceId: "alex",
88
color: "var(--color-indigo-500)",
89
},
90
{
91
id: "client-call",
92
title: "Client call",
93
start: at(5, 14, 0),
94
end: at(5, 15, 0),
95
resourceId: "mia",
96
color: "var(--color-amber-500)",
97
},
98
{
99
id: "team-offsite",
100
title: "Team offsite",
101
start: day(4),
102
end: day(6),
103
allDay: true,
104
color: "var(--color-rose-500)",
105
},
106
{
107
id: "sprint-planning",
108
title: "Sprint planning",
109
start: at(9, 9, 30),
110
end: at(9, 10, 30),
111
resourceId: "sam",
112
color: "var(--color-blue-500)",
113
},
114
{
115
id: "quarterly-review",
116
title: "Quarterly review",
117
start: at(17, 13, 0),
118
end: at(17, 14, 30),
119
resourceId: "alex",
120
color: "var(--color-cyan-500)",
121
},
122
];
123
}
124
125
/**
126
* Custom chip content for a couple of events - proof that the chip is fully
127
* yours to shape via `renderEvent`. Returning undefined for everything else
128
* falls back to the built-in dot + title + time.
129
*/
130
function renderEventContent(props: EventCalendarRenderEventProps) {
131
const event = props.occurrence.event;
132
133
// Attendee initials in place of the leading color dot; a thin ring keeps the
134
// overlap crisp at chip size.
135
if (event.id === "design-review") {
136
return (
137
<>
138
<span class="-space-x-1 flex shrink-0">
139
<span class="flex size-4 items-center justify-center rounded-full bg-violet-500 font-semibold text-[8px] text-white ring-1 ring-background">
140
MJ
141
</span>
142
<span class="flex size-4 items-center justify-center rounded-full bg-sky-500 font-semibold text-[8px] text-white ring-1 ring-background">
143
AL
144
</span>
145
</span>
146
<span class="truncate font-medium">{event.title}</span>
147
</>
148
);
149
}
150
151
// Title with a trailing status pill. The dot, title and pill share one flex
152
// row so the leading dot stays glued to the label - a stacked (flex-col)
153
// timed-grid chip would otherwise drop the dot onto its own line.
154
if (event.id === "client-call") {
155
return (
156
<span class="flex w-full min-w-0 items-center gap-1.5">
157
<span aria-hidden class="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)" />
158
<span class="truncate font-medium">{event.title}</span>
159
<span class="ms-auto shrink-0 rounded bg-(--ec-event-color)/25 px-1 font-semibold text-[10px]">
160
30m
161
</span>
162
</span>
163
);
164
}
165
166
return undefined;
167
}
168
169
/** Resource columns get an avatar next to the member name. */
170
function renderResourceHeaderContent(props: { resource: EventCalendarResource }) {
171
return (
172
<span class="flex items-center gap-1.5">
173
<Avatar size="sm">
174
<AvatarFallback class="font-medium text-[10px]">
175
{INITIALS[props.resource.id] ?? props.resource.title.slice(0, 2).toUpperCase()}
176
</AvatarFallback>
177
</Avatar>
178
<span class="truncate">{props.resource.title}</span>
179
</span>
180
);
181
}
182
183
/** Styled hover tooltip content (only rendered while `eventTooltip` is on). */
184
function renderEventTooltipContent(props: {
185
occurrence: EventCalendarOccurrence;
186
label: string | undefined;
187
}) {
188
const resource = TEAM.find((member) => member.id === props.occurrence.event.resourceId);
189
return (
190
<span class="flex flex-col gap-0.5">
191
<span class="font-medium">{props.label}</span>
192
<Show when={resource}>
193
{(member) => <span class="text-[11px] opacity-80">Owner: {member().title}</span>}
194
</Show>
195
</span>
196
);
197
}
198
199
/**
200
* i18n presets - each language ships a date-fns `locale` (localizes every
201
* formatted date: weekday headers, month title, time gutter) plus an `i18n`
202
* override map for the static UI strings the locale can't reach (Today, view
203
* names, "+N more"). Arabic also flips the whole calendar to right-to-left.
204
* English is the built-in default, so it leaves both undefined.
205
*/
206
interface DemoLocale {
207
value: string;
208
/** Native language name, shown in the picker. */
209
label: string;
210
locale: Locale | undefined;
211
dir: "ltr" | "rtl";
212
i18n: EventCalendarI18nOverrides | undefined;
213
}
214
215
const LOCALES: DemoLocale[] = [
216
{ value: "en", label: "English", locale: undefined, dir: "ltr", i18n: undefined },
217
{
218
value: "de",
219
label: "Deutsch",
220
locale: de,
221
dir: "ltr",
222
i18n: {
223
labels: {
224
today: "Heute",
225
allDay: "Ganztägig",
226
noEvents: "Keine Termine",
227
more: (count) => `+${count} weitere`,
228
},
229
viewNames: {
230
month: "Monat",
231
week: "Woche",
232
day: "Tag",
233
days: (count) => `${count} Tage`,
234
agenda: "Agenda",
235
resource: "Zeitraster",
236
},
237
},
238
},
239
{
240
value: "fr",
241
label: "Français",
242
locale: fr,
243
dir: "ltr",
244
i18n: {
245
labels: {
246
today: "Aujourd'hui",
247
allDay: "Journée entière",
248
noEvents: "Aucun événement",
249
more: (count) => `+${count} autres`,
250
},
251
viewNames: {
252
month: "Mois",
253
week: "Semaine",
254
day: "Jour",
255
days: (count) => `${count} jours`,
256
agenda: "Agenda",
257
resource: "Grille horaire",
258
},
259
},
260
},
261
{
262
value: "es",
263
label: "Español",
264
locale: es,
265
dir: "ltr",
266
i18n: {
267
labels: {
268
today: "Hoy",
269
allDay: "Todo el día",
270
noEvents: "Sin eventos",
271
more: (count) => `+${count} más`,
272
},
273
viewNames: {
274
month: "Mes",
275
week: "Semana",
276
day: "Día",
277
days: (count) => `${count} días`,
278
agenda: "Agenda",
279
resource: "Cuadrícula",
280
},
281
},
282
},
283
{
284
value: "ja",
285
label: "日本語",
286
locale: ja,
287
dir: "ltr",
288
i18n: {
289
labels: {
290
today: "今日",
291
allDay: "終日",
292
noEvents: "予定なし",
293
more: (count) => `他${count}件`,
294
},
295
viewNames: {
296
month: "月",
297
week: "週",
298
day: "日",
299
days: (count) => `${count}日間`,
300
agenda: "予定",
301
resource: "タイムグリッド",
302
},
303
},
304
},
305
{
306
value: "ar",
307
label: "العربية",
308
locale: ar,
309
dir: "rtl",
310
i18n: {
311
labels: {
312
today: "اليوم",
313
allDay: "طوال اليوم",
314
noEvents: "لا توجد أحداث",
315
more: (count) => `+${count} المزيد`,
316
},
317
viewNames: {
318
month: "شهر",
319
week: "أسبوع",
320
day: "يوم",
321
days: (count) => `${count} أيام`,
322
agenda: "جدول الأعمال",
323
resource: "شبكة زمنية",
324
},
325
},
326
},
327
];
328
329
/** Display time zones - all event math and rendering happen in the chosen
330
* zone, so switching it visibly shifts every event's clock time. */
331
const TIME_ZONES: Array<{ value: string; label: string; zone?: string }> = [
332
{ value: "local", label: "Browser" },
333
{ value: "ny", label: "New York", zone: "America/New_York" },
334
{ value: "london", label: "London", zone: "Europe/London" },
335
{ value: "tokyo", label: "Tokyo", zone: "Asia/Tokyo" },
336
{ value: "kolkata", label: "Kolkata", zone: "Asia/Kolkata" },
337
];
338
339
/** Everything the settings panel drives, as one resettable object. */
340
interface DemoSettings {
341
viewSettings: EventCalendarViewSettings;
342
interactions: EventCalendarInteractions;
343
weekStartsOn: 0 | 1;
344
dayStartHour: number;
345
dayEndHour: number;
346
interval: number;
347
snapDuration: number;
348
eventTooltip: boolean;
349
showDayAddButton: boolean;
350
localeId: string;
351
timeZoneId: string;
352
}
353
354
const DEFAULT_SETTINGS: DemoSettings = {
355
viewSettings: { weekends: true, weekNumbers: false, nowIndicator: true, offDays: false },
356
interactions: { drag: true, resize: true, selectSlot: true },
357
weekStartsOn: 0,
358
dayStartHour: 0,
359
dayEndHour: 24,
360
interval: 60,
361
snapDuration: 15,
362
eventTooltip: false,
363
showDayAddButton: false,
364
localeId: "en",
365
timeZoneId: "local",
366
};
367
368
type NumberOption = { value: number; label: string };
369
type TextOption = { value: string; label: string };
370
371
function SettingsSwitch(props: {
372
id: string;
373
label: string;
374
checked: boolean;
375
onChange: (checked: boolean) => void;
376
}) {
377
return (
378
<div class="flex items-center justify-between gap-4">
379
<Label for={props.id} class="font-normal text-sm">
380
{props.label}
381
</Label>
382
<Switch id={props.id} size="sm" checked={props.checked} onChange={props.onChange} />
383
</div>
384
);
385
}
386
387
function SettingsSelect(props: {
388
label: string;
389
value: number;
390
options: NumberOption[];
391
onChange: (value: number) => void;
392
}) {
393
const selected = () => props.options.find((option) => option.value === props.value);
394
return (
395
<div class="flex items-center justify-between gap-4">
396
<span class="text-sm">{props.label}</span>
397
<Select<NumberOption>
398
options={props.options}
399
optionValue="value"
400
optionTextValue="label"
401
value={selected()}
402
onChange={(option) => option && props.onChange(option.value)}
403
itemComponent={(itemProps) => (
404
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.label}</SelectItem>
405
)}
406
>
407
<SelectTrigger size="sm" class="w-28" aria-label={props.label}>
408
<SelectValue<NumberOption>>{(state) => state.selectedOption().label}</SelectValue>
409
</SelectTrigger>
410
<SelectContent />
411
</Select>
412
</div>
413
);
414
}
415
416
/** String-keyed sibling of SettingsSelect, for the language/time-zone pickers. */
417
function SettingsTextSelect(props: {
418
label: string;
419
value: string;
420
options: TextOption[];
421
onChange: (value: string) => void;
422
}) {
423
const selected = () => props.options.find((option) => option.value === props.value);
424
return (
425
<div class="flex items-center justify-between gap-4">
426
<span class="text-sm">{props.label}</span>
427
<Select<TextOption>
428
options={props.options}
429
optionValue="value"
430
optionTextValue="label"
431
value={selected()}
432
onChange={(option) => option && props.onChange(option.value)}
433
itemComponent={(itemProps) => (
434
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.label}</SelectItem>
435
)}
436
>
437
<SelectTrigger size="sm" class="w-36" aria-label={props.label}>
438
<SelectValue<TextOption>>{(state) => state.selectedOption().label}</SelectValue>
439
</SelectTrigger>
440
<SelectContent />
441
</Select>
442
</div>
443
);
444
}
445
446
export default function EventCalendarDemo() {
447
const events = buildEvents(new Date());
448
// apiRef takes the Solid ref convention: a setter that receives the API.
449
let api: EventCalendarApi | undefined;
450
let newEventCount = 0;
451
const [settings, setSettings] = createSignal<DemoSettings>(DEFAULT_SETTINGS);
452
// Mirror the active view so the settings panel can show the time-grid
453
// internals tab only where those options are visible (week/day/N-days and
454
// the resource time grid - month and agenda render no hour track).
455
const [view, setView] = createSignal<CalendarView>("month");
456
const [tab, setTab] = createSignal("view");
457
const isTimeGridView = () => view() !== "month" && view() !== "agenda";
458
const activeTab = () => (tab() === "time" && !isTimeGridView() ? "view" : tab());
459
460
const activeLocale = () =>
461
LOCALES.find((entry) => entry.value === settings().localeId) ?? LOCALES[0];
462
const activeTimeZone = () =>
463
TIME_ZONES.find((entry) => entry.value === settings().timeZoneId) ?? TIME_ZONES[0];
464
465
const patch = (partial: Partial<DemoSettings>) =>
466
setSettings((current) => ({ ...current, ...partial }));
467
const patchViewSettings = (partial: EventCalendarViewSettings) =>
468
patch({ viewSettings: { ...settings().viewSettings, ...partial } });
469
const patchInteractions = (partial: Partial<EventCalendarInteractions>) =>
470
patch({ interactions: { ...settings().interactions, ...partial } });
471
472
// Add a one-hour event at noon today and jump to it - a minimal stand-in for
473
// a real "create event" dialog.
474
const addEvent = () => {
475
if (!api) return;
476
const start = setHours(startOfDay(new Date()), 12);
477
const end = addMinutes(start, 60);
478
api.addEvent({
479
id: `new-event-${newEventCount++}`,
480
title: "New event",
481
start,
482
end,
483
resourceId: "alex",
484
color: "var(--color-blue-500)",
485
});
486
api.goTo(start);
487
};
488
489
return (
490
<div class="w-full p-4" dir={activeLocale().dir}>
491
<Card class="w-full py-0">
492
<CardContent class="p-0">
493
<EventCalendar
494
defaultEvents={events}
495
defaultView="month"
496
onViewChange={setView}
497
resources={TEAM}
498
apiRef={(instanceApi) => {
499
api = instanceApi;
500
}}
501
renderEvent={renderEventContent}
502
renderResourceHeader={renderResourceHeaderContent}
503
renderEventTooltip={renderEventTooltipContent}
504
locale={activeLocale().locale}
505
i18n={activeLocale().i18n}
506
timeZone={activeTimeZone().zone}
507
viewSettings={settings().viewSettings}
508
onViewSettingsChange={(viewSettings) => patch({ viewSettings })}
509
interactions={settings().interactions}
510
onInteractionsChange={(interactions) => patch({ interactions })}
511
weekStartsOn={settings().weekStartsOn}
512
dayStartHour={settings().dayStartHour}
513
dayEndHour={settings().dayEndHour}
514
interval={settings().interval}
515
snapDuration={settings().snapDuration}
516
eventTooltip={settings().eventTooltip}
517
showDayAddButton={settings().showDayAddButton}
518
// Object form of offDays: the marker class hook is named `class`
519
// in this port, unlike the React original.
520
offDays={{ weekendDays: [0, 6], class: "bg-muted/40" }}
521
class="h-[640px] w-full"
522
>
523
<div class="flex flex-wrap items-center gap-2 pe-2">
524
<EventCalendarNav class="min-w-0 flex-1" />
525
<EventCalendarToolbar>
526
{/* Not part of the default nav - compose it wherever it fits. */}
527
<EventCalendarDatePicker />
528
<Popover placement="bottom-end" gutter={8}>
529
<PopoverTrigger as={Button} variant="outline" size="sm">
530
<SlidersHorizontal class="size-4" aria-hidden="true" />
531
Settings
532
</PopoverTrigger>
533
<PopoverContent class="w-80">
534
<Tabs value={activeTab()} onChange={setTab}>
535
<TabsList class="w-full">
536
<TabsTrigger value="view" class="flex-1">
537
View
538
</TabsTrigger>
539
{/* time-grid internals only exist where an hour track
540
renders, so the tab follows the active view */}
541
<Show when={isTimeGridView()}>
542
<TabsTrigger value="time" class="flex-1">
543
Time grid
544
</TabsTrigger>
545
</Show>
546
<TabsTrigger value="behavior" class="flex-1">
547
Behavior
548
</TabsTrigger>
549
<TabsTrigger value="region" class="flex-1">
550
Region
551
</TabsTrigger>
552
</TabsList>
553
<TabsContent value="view" class="flex flex-col gap-3 pt-3">
554
<SettingsSwitch
555
id="ec-set-weekends"
556
label="Weekends"
557
checked={settings().viewSettings.weekends ?? true}
558
onChange={(weekends) => patchViewSettings({ weekends })}
559
/>
560
<SettingsSwitch
561
id="ec-set-week-numbers"
562
label="Week numbers"
563
checked={settings().viewSettings.weekNumbers ?? false}
564
onChange={(weekNumbers) => patchViewSettings({ weekNumbers })}
565
/>
566
<SettingsSwitch
567
id="ec-set-now"
568
label="Now indicator"
569
checked={settings().viewSettings.nowIndicator ?? true}
570
onChange={(nowIndicator) => patchViewSettings({ nowIndicator })}
571
/>
572
<SettingsSwitch
573
id="ec-set-off-days"
574
label="Mark off days"
575
checked={settings().viewSettings.offDays ?? false}
576
onChange={(offDays) => patchViewSettings({ offDays })}
577
/>
578
<SettingsSwitch
579
id="ec-set-day-add"
580
label="Day add button"
581
checked={settings().showDayAddButton}
582
onChange={(showDayAddButton) => patch({ showDayAddButton })}
583
/>
584
{/* week start shapes month and week grids alike, so
585
it lives here rather than in time-grid internals */}
586
<SettingsSelect
587
label="Week starts"
588
value={settings().weekStartsOn}
589
options={[
590
{ value: 0, label: "Sunday" },
591
{ value: 1, label: "Monday" },
592
]}
593
onChange={(weekStartsOn) =>
594
patch({ weekStartsOn: weekStartsOn === 1 ? 1 : 0 })
595
}
596
/>
597
</TabsContent>
598
<TabsContent value="time" class="flex flex-col gap-3 pt-3">
599
<SettingsSelect
600
label="Day starts"
601
value={settings().dayStartHour}
602
options={[
603
{ value: 0, label: "00:00" },
604
{ value: 6, label: "06:00" },
605
{ value: 8, label: "08:00" },
606
]}
607
onChange={(dayStartHour) => patch({ dayStartHour })}
608
/>
609
<SettingsSelect
610
label="Day ends"
611
value={settings().dayEndHour}
612
options={[
613
{ value: 18, label: "18:00" },
614
{ value: 20, label: "20:00" },
615
{ value: 24, label: "24:00" },
616
]}
617
onChange={(dayEndHour) => patch({ dayEndHour })}
618
/>
619
<SettingsSelect
620
label="Grid interval"
621
value={settings().interval}
622
options={[
623
{ value: 30, label: "30 min" },
624
{ value: 60, label: "60 min" },
625
]}
626
onChange={(interval) => patch({ interval })}
627
/>
628
<SettingsSelect
629
label="Drag snap"
630
value={settings().snapDuration}
631
options={[
632
{ value: 5, label: "5 min" },
633
{ value: 15, label: "15 min" },
634
{ value: 30, label: "30 min" },
635
]}
636
onChange={(snapDuration) => patch({ snapDuration })}
637
/>
638
</TabsContent>
639
<TabsContent value="behavior" class="flex flex-col gap-3 pt-3">
640
<SettingsSwitch
641
id="ec-set-drag"
642
label="Drag to move"
643
checked={settings().interactions.drag}
644
onChange={(drag) => patchInteractions({ drag })}
645
/>
646
<SettingsSwitch
647
id="ec-set-resize"
648
label="Drag to resize"
649
checked={settings().interactions.resize}
650
onChange={(resize) => patchInteractions({ resize })}
651
/>
652
<SettingsSwitch
653
id="ec-set-select-slot"
654
label="Drag to create"
655
checked={settings().interactions.selectSlot}
656
onChange={(selectSlot) => patchInteractions({ selectSlot })}
657
/>
658
<SettingsSwitch
659
id="ec-set-tooltip"
660
label="Event tooltips"
661
checked={settings().eventTooltip}
662
onChange={(eventTooltip) => patch({ eventTooltip })}
663
/>
664
</TabsContent>
665
<TabsContent value="region" class="flex flex-col gap-3 pt-3">
666
<SettingsTextSelect
667
label="Language"
668
value={settings().localeId}
669
options={LOCALES.map((entry) => ({
670
value: entry.value,
671
label: entry.label,
672
}))}
673
onChange={(localeId) => patch({ localeId })}
674
/>
675
<SettingsTextSelect
676
label="Time zone"
677
value={settings().timeZoneId}
678
options={TIME_ZONES.map((entry) => ({
679
value: entry.value,
680
label: entry.label,
681
}))}
682
onChange={(timeZoneId) => patch({ timeZoneId })}
683
/>
684
<p class="text-muted-foreground text-xs leading-relaxed">
685
Language switches the date-fns locale and every UI label. Time zone shifts
686
all event times. Arabic also flips the calendar to right-to-left.
687
</p>
688
</TabsContent>
689
</Tabs>
690
<Button
691
variant="outline"
692
size="sm"
693
class="mt-4 w-full"
694
onClick={() => setSettings(DEFAULT_SETTINGS)}
695
>
696
Reset to defaults
697
</Button>
698
</PopoverContent>
699
</Popover>
700
<Button size="sm" onClick={addEvent}>
701
<Plus class="size-4" aria-hidden="true" />
702
New event
703
</Button>
704
</EventCalendarToolbar>
705
</div>
706
<EventCalendarContent />
707
</EventCalendar>
708
</CardContent>
709
</Card>
710
</div>
711
);
712
}

The demo above is the whole feature surface in one example: every view (month, week, day, N-days, agenda, and the resource time grid), a multi-day all-day bar, a recurring series, custom chips via renderEvent, a composed date picker, and a tabbed Settings panel driving view settings, time-grid options, and interactions as controlled props — with a reset back to defaults.

EventCalendar separates the calendar engine from the calendar UI. A Solid store (useEventCalendarState) owns events, view, date, selection, and interactions with controlled and uncontrolled modes for every state pair; the shipped view components (month, week, day, N-days, agenda, and a resource day grid) render from that store through fine-grained accessors, so a write only invalidates the cells that actually changed. Events are yours: the calendar never persists anything, it proposes changes through onEventUpdate and you accept, adjust, or reject them.

The composition contract is a provider plus slots: EventCalendar wraps EventCalendarNav, an optional EventCalendarToolbar, and EventCalendarContent. Recurrence (an RFC 5545 subset, structured rules or raw RRULE strings), display time zones via @date-fns/tz, pointer-based drag, resize, and drag-create, and per-key i18n overrides are built in.

Installation

pnpm dlx shadcn@latest add @zaidan/event-calendar
npx shadcn@latest add @zaidan/event-calendar
yarn dlx shadcn@latest add @zaidan/event-calendar
bunx --bun shadcn@latest add @zaidan/event-calendar

Usage

1
import { createSignal } from "solid-js";
2
import {
3
type CalendarEvent,
4
EventCalendar,
5
EventCalendarContent,
6
EventCalendarNav,
7
} from "~/components/blocks/event-calendar";
1
const [events, setEvents] = createSignal<CalendarEvent[]>(initialEvents);
2
3
return (
4
<EventCalendar
5
events={events()}
6
onEventsChange={setEvents}
7
defaultView="week"
8
class="h-[600px]"
9
>
10
<EventCalendarNav />
11
<EventCalendarContent />
12
</EventCalendar>
13
);

Pass defaultEvents for uncontrolled state or events + onEventsChange for controlled state; the same pairing exists for view, date, dayCount, selection, interactions, and viewSettings. In the default scrollMode="contained" the calendar fills its container and scrolls internally, so give the root an explicit height. Every timing change, whether from a drag, a resize, or api.updateEvent, funnels through onEventUpdate: return false to reject and revert, return nothing (or true) to accept, or return { start, end, allDay } to accept with an adjustment, then persist from onEventsChange or inside onEventUpdate itself. Use onRangeChange to fetch remote events for the visible range, and apiRef (or a hoisted useEventCalendarState instance passed as calendar) for imperative control from outside the tree.

Solid API notes

This is a port of the React original, so a few shapes differ. Everything else — component names, prop names, data-slot attributes, classes, and behavior — is unchanged.

  • No render prop. Every component renders a plain element and forwards ComponentProps, so style it with class and compose with children instead.
  • class, not className — including EventCalendarOffDaysConfig.class. The class maps keep their upstream names: classNames, dayClassName, todayClassName.
  • The instance has no subscribe. EventCalendarInstance is { getState, api, settings, internals }; fine-grained reads replace the snapshot/listener machinery. getState() returns a live object of getters — read a field inside a memo or JSX and you subscribe to exactly that field.
  • Selector-style hooks return accessors. useEventCalendarSelector, useEventCalendarOccurrences, and useNow return Accessor<T>; call them. The "bag" hooks (useEventCalendarView, useEventCalendarNavigation, useEventCalendarSelection, useEventCalendarInteractions, useEventCalendarDay, useEventCalendarWeek, useEventCalendarViewSettings) return objects of getters, so property access reads exactly like the React version.
  • Day/range arguments accept accessors. useEventCalendarDay, useEventCalendarWeek, and useEventCalendarOccurrences take Date | (() => Date) (respectively a range or an accessor of one), so they stay reactive without an effect.
  • apiRef follows the Solid ref convention. Pass a setter (api) => void, or a { current } box if you prefer the React shape. Both are the exported EventCalendarApiRef type.
  • JSX.Element replaces ReactNode, MouseEvent replaces React.MouseEvent, JSX.CSSProperties replaces CSSProperties, and gesture callbacks take a native PointerEvent (there is no e.nativeEvent).
  • onDblClick is the DOM double-click prop on EventCalendarEvent (Solid's name). The calendar-level callback is still onEventDoubleClick.
  • style is object-only on EventCalendarEvent, EventCalendarTimeGrid, and EventCalendarResourceView: those three merge their own CSS variable into it and Solid has no style-merging spread.
  • EventCalendarProps omits the DOM onSelectionChange handler, because the calendar callback of the same name wins.
  • The chip's data-slot="event-calendar-event" is calendar-owned and re-asserted last, so it cannot be overridden by a consumer prop; the drag carry clone and several view queries depend on it.
  • useEventCalendarSettingsVersion is vestigial. It still returns a number, but fine-grained reactivity makes it unnecessary for rendering.

API Reference

EventCalendar

The root provider and container. It creates (or adopts) the calendar instance, provides it via context, and renders a flex-column div. Besides the props below, it accepts every option and callback listed under State options and Callbacks, every display prop listed under View configuration, and every other div prop.

PropTypeDefaultDescription
calendarEventCalendarInstance<TData>-Adopt a hoisted useEventCalendarState instance; option props are then ignored (a dev warning fires if both are passed).
apiRefEventCalendarApiRef<TData>-Imperative escape hatch: a setter (api) => void or a { current } box, filled during setup.
childrenJSX.Element-Composed slots, typically EventCalendarNav, EventCalendarToolbar, and EventCalendarContent.
classstring-Additional CSS classes for the root element.

The root renders data-slot="event-calendar" plus an aria-live="polite" announcer element.


EventCalendarNav

Default composed navigation: Today, view switcher, prev/next, title, and a trailing spacer. Pass children to use it as a pure layout shell and compose the nav parts yourself. Renders as a div.

PropTypeDefaultDescription
showViewSwitcherbooleantrueRender the view switcher in the composed layout; turn off for fixed-view embeds.
childrenJSX.Element-Replaces the default composed layout entirely.
classstring-Additional CSS classes for the nav container.

EventCalendarNavToday

The "Today" navigation button; resets the calendar to now. It follows the configured navButtonVariant / navButtonSize and the classNames.navButton hook, and gets data-active while the anchor period contains now. EventCalendarNavPrev and EventCalendarNavNext share the same props.

PropTypeDefaultDescription
tooltipJSX.Element | null-Hover/focus-visible hint. Today defaults to the current date; Prev/Next default to their i18n labels. Pass null to disable this one button.
childrenJSX.Element-Replaces the default label or chevron icon.

EventCalendarNavPrev

The previous-period navigation button; steps the anchor date one period back for the current view. Same props as EventCalendarNavToday, except tooltip defaults to the i18n "previous" label and children replaces the default chevron icon.


EventCalendarNavNext

The next-period navigation button; steps the anchor date one period forward for the current view. Same props as EventCalendarNavToday, except tooltip defaults to the i18n "next" label and children replaces the default chevron icon.


EventCalendarTitle

The current period title (from i18n.functions.formatTitle), marked aria-live="polite".

PropTypeDefaultDescription
format(ctx: { title: string }) => JSX.Element-Wrap or transform the computed title text.

EventCalendarViewSwitcher

Dropdown listing the available views (the resolved views option) with optional keyboard-shortcut hints; when the days view is enabled, one item per dayCountPresets entry is offered. Built on the Kobalte Dropdown Menu, so items activate through onSelect and the menu is keyboard-operable.

PropTypeDefaultDescription
tooltipJSX.Element | null-Hover-only hint (force-closed while the menu is open); defaults to the "Select view" label.
childrenJSX.Element-Replaces the default trigger content (current view name + chevron).

EventCalendarDatePicker

Optional go-to-date popover (the Zaidan Calendar), not part of the default nav; compose it yourself. It is view-aware: week, N-days, and agenda highlight the whole active range, other views select a single date.

PropTypeDefaultDescription
mode"auto" | "single" | "range""auto""auto" resolves to "range" for week/N-days/agenda and "single" otherwise.
tooltipJSX.Element | nullnullNo tooltip by default (the button opens an overlay); pass a node to opt in.
childrenJSX.Element-Replaces the default calendar icon.

EventCalendarToolbar

Free slot for consumer toolbar buttons; a pure layout shell (flex items-center gap-2) with the classNames.toolbar hook. No props beyond standard div props.


EventCalendarContent

Active-view switchboard: renders the component registered for the current view and exposes data-view / data-loading attributes. While loading is true it dims and disables pointer events.

PropTypeDefaultDescription
componentsPartial<Record<CalendarView, Component>>-Swap individual view implementations (merged over the root components prop).
childrenJSX.Element-Replaces the switchboard entirely; read useEventCalendarView() inside.

EventCalendarMonthView

ARIA-grid month view: week rows, day cells, continuous multi-day bars in a lane overlay, "+N more" overflow, week numbers, and the day add affordance.

PropTypeDefaultDescription
maxEventsPerCellnumber | "auto"-Per-view override of the maxEventsPerCell view config (bars plus chips).

EventCalendarTimeGrid

The shared week/day/N-days engine: hour gutter, minute-positioned event blocks with overlap packing, the all-day row, drag ghosts, and the now indicator. You usually render one of the wrappers below instead.

PropTypeDefaultDescription
view"week" | "day" | "days"-Required. Which time-based view this grid renders.
dayStartHournumber-Per-view override of the dayStartHour option.
dayEndHournumber-Per-view override of the dayEndHour option.
showAllDaybooleantrueRender the all-day row above the time track.
intervalnumber-Gutter/gridline interval in minutes (clamped 5 to 240); defaults to the interval view config.
styleJSX.CSSProperties-Object form only: the grid merges its own --ec-hour-height into it.

EventCalendarWeekView

Thin wrapper around EventCalendarTimeGrid with view preset to "week". Accepts every EventCalendarTimeGrid prop except view.


EventCalendarDayView

Thin wrapper around EventCalendarTimeGrid with view preset to "day". Accepts every EventCalendarTimeGrid prop except view.


EventCalendarDaysView

Thin wrapper around EventCalendarTimeGrid with view preset to "days". Accepts every EventCalendarTimeGrid prop except view.


EventCalendarAgendaView

Chronological list of the upcoming window, grouped by day with a date gutter per group, one row per occurrence, and a compact empty state. The window length comes from the agendaDayCount option, so the view has no props beyond standard div props.


EventCalendarResourceView

Resource-columns day grid for booking scenarios: one time axis, one column per leaf resource (from the resources option, flattened depth-first), with full drag, resize, and drag-create across columns.

PropTypeDefaultDescription
dayStartHournumber-Per-view override of the dayStartHour option.
dayEndHournumber-Per-view override of the dayEndHour option.
showAllDaybooleantrueRender the all-day row above the time track.
intervalnumber-Gutter/gridline interval in minutes (clamped 5 to 240); defaults to the interval view config.
styleJSX.CSSProperties-Object form only: the view merges its own --ec-hour-height into it.

EventCalendarEvent

The one interactive chip used by every view. The wrapper owns positioning, a11y, selection, drag/resize listeners, and data attributes; the content comes from children, the root renderEvent / renderAgendaEvent override, or the built-in default. Accepts every button prop except children and style.

PropTypeDefaultDescription
segmentEventCalendarSegment<TData>-Required. The per-day slice this chip renders.
childrenJSX.Element-Replaces the chip CONTENT; the wrapper stays calendar-owned.
previewbooleanfalseStatic, inert clone used for the drag ghost: no gestures, handles, focus, or selection state.
styleJSX.CSSProperties-Object form only: the chip merges its own --ec-event-color into it.

EventCalendarApi

The imperative API, available as instance.api, through apiRef, or from any hook's instance. Reading methods reflect the current state; writing methods respect controlled props (they call the matching on*Change callback and only mutate internal state for uncontrolled fields).

MethodSignatureDescription
next / prev() => voidStep the anchor date one period forward or backward for the current view.
today() => voidJump to now.
goTo(date: Date) => voidJump to a date.
setView(view: CalendarView, opts?: { dayCount?: number }) => voidSwitch view; unknown views fall back to the first enabled view with a dev warning.
setDayCount(count: number) => voidSet the N-day count (min 1).
getEvents() => CalendarEvent<TData>[]Current events array.
getEvent(id: string) => CalendarEvent<TData> | undefinedFind one event by id.
setEvents(events: CalendarEvent<TData>[]) => voidReplace the events array.
addEvent(event: CalendarEvent<TData>) => voidAppend an event.
updateEvent(id: string, patch: Partial<CalendarEvent<TData>>) => voidPatch an event; timing patches route through onEventUpdate with source: "api".
removeEvent(id: string) => voidRemove an event.
getOccurrences(range?: EventCalendarDateRange) => EventCalendarOccurrence<TData>[]Expanded, sorted occurrences; defaults to the visible range.
getOccurrencesForDay(day: Date) => EventCalendarOccurrence<TData>[]Deduplicated occurrences touching one day.
findOverlapping(candidate: { start: Date; end: Date; excludeEventId?: string }) => EventCalendarOccurrence<TData>[]Occurrences overlapping a candidate range.
select(selection: Partial<EventCalendarSelection>) => voidPatch the selection.
selectEvent(key: string, opts?: { additive?: boolean }) => voidSelect an occurrence key; additive toggles it within the current set.
clearSelection() => voidClear event keys and the committed slot.
setInteractions(patch: Partial<EventCalendarInteractions>) => voidToggle drag, resize, or slot selection.
setViewSettings(patch: EventCalendarViewSettings) => voidPatch the user display toggles.
getVisibleRange() => EventCalendarDateRangeThe full rendered grid range (including outside days); fetch remote data for this.
getActiveRange() => EventCalendarDateRangeThe logical period (the month or week itself).
toZoned(date: Date) => DateThe instant re-expressed in the calendar's display time zone (a TZDate).
scrollToTime(time: Date | number) => voidScroll a time grid to an instant or minutes-from-day-start; no-op outside time-grid views.

CalendarEvent

Your event objects. TData is a fully generic consumer payload.

PropertyTypeDefaultDescription
idstring-Required. Stable event id.
titlestring-Required. Display title.
startDate-Required. Start instant (consumers parse ISO strings themselves).
endDate-Required. Exclusive end instant; must be greater than or equal to start.
allDayboolean-Render as an all-day bar; date comparison is day-granular in the display zone.
recurrenceEventCalendarRecurrenceRule | string-Structured rule or a raw "RRULE:..." line.
recurringEventIdstring-Marks this event as an edited single occurrence of that series.
originalStartDate-Which occurrence it replaces (RECURRENCE-ID semantics).
colorstring-Token or CSS color; flows to the --ec-event-color CSS variable.
readOnlyboolean-Excluded from drag and resize regardless of the interactions state.
draggableboolean-Per-event override; the default comes from interactions.drag.
resizableboolean-Per-event override; the default comes from interactions.resize.
prioritynumber-Packing prominence; feeds the getEventPriority ordering.
zIndexnumber-Explicit stacking override; wins over the computed z-index.
resourceIdstring-Bookable resource this event belongs to (resource view).
dataTData-Consumer payload, fully generic.

EventCalendarResource

A bookable resource (room, person, equipment). Nested resources render their leaves as booking columns in the resource view.

PropertyTypeDefaultDescription
idstring-Required. Stable resource id.
titlestring-Required. Column header text (or renderResourceHeader).
colorstring-Token or CSS color for subtle row/column accents.
childrenEventCalendarResource[]-Child resources; only leaves become booking columns.

EventCalendarRecurrenceRule

The structured RFC 5545 subset. A raw RRULE string with the same parts is equally accepted on event.recurrence.

PropertyTypeDefaultDescription
freq"daily" | "weekly" | "monthly" | "yearly"-Required. Recurrence frequency.
intervalnumber1Every N periods.
countnumber-Total number of occurrences.
untilDate-Inclusive end instant.
byWeekdayArray<EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }>-Weekdays; ordinals (2TU, -1FR) apply to monthly/yearly rules.
byMonthDaynumber[]-Days of the month; negative values count back from month end.
byMonthnumber[]-Months (1 to 12) for yearly rules.
weekStartEventCalendarWeekday-WKST equivalent.
exDatesDate[]-Excluded instants; matching occurrences are removed (they still consume their count slot).
rDatesDate[]-Extra instants added to the series, each with the event's own duration.

EventCalendarWeekday is "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU". For rule parts outside this subset, plug a full RRULE engine through the getOccurrences option; unsupported parts throw EventCalendarRecurrenceError.


EventCalendarOccurrence

One expanded instance of an event, as passed to click callbacks and returned by api.getOccurrences.

PropertyTypeDescription
keystringStable per instance: `${event.id}::${startISO}`.
eventIdstringThe source event id.
eventCalendarEvent<TData>The source event.
start / endDateThis instance's instants (end exclusive).
allDaybooleanResolved all-day flag.
isRecurringbooleanWhether it came from a recurrence expansion.
recurrenceIndexnumberSeries ordinal, when recurring.

EventCalendarSegment

A per-day slice of an occurrence, produced by the event index and consumed by EventCalendarEvent and the layout hooks. Positional fields are filled per surface: startMin / endMin for timed blocks, lane / column / columnCount / columnSpan for packing, and rowIndex / colStart / colSpan for month/all-day bars. isStart / isEnd and continuesBefore / continuesAfter mark cross-day edges.


EventCalendarProposedUpdate

The payload of onEventUpdate and canDropEvent: a proposed timing (and optionally resource) change.

PropertyTypeDescription
eventCalendarEvent<TData>The event being changed.
occurrenceEventCalendarOccurrence<TData> | nullThe dragged occurrence; null when source is "api".
startDateProposed start.
endDateProposed exclusive end.
allDaybooleanProposed all-day flag.
resourceIdstringProposed resource when the gesture crossed resource columns.
source"drag" | "resize-start" | "resize-end" | "keyboard" | "api"What produced the proposal.

The onEventUpdate return type is EventCalendarUpdateResult: false rejects and reverts, void or true accepts, and { start?, end?, allDay? } accepts with an adjustment.


EventCalendarSlotInfo

The payload of onSlotClick. A click is a point, not a range.

PropertyTypeDescription
dateDateClicked instant (snapped in time grids) or day (day-granular surfaces).
endDatePresent for timed slots: date plus slotDuration.
allDaybooleanWhether the click landed on a day-granular surface.
viewCalendarViewThe view the click happened in.
resourceIdstringPresent when the click happened inside a resource column.

EventCalendarSlotDraft

The drag-create rectangle, passed to onSelectSlot and canSelectSlot. The in-progress draft lives in state as slotDraft and is cleared on commit or cancel; the committed slot selection lives in selection.slot.

PropertyTypeDescription
startDateDraft start.
endDateDraft exclusive end.
allDaybooleanWhether the draft was drawn on a day-granular surface.
viewCalendarViewThe view the draft was drawn in.
resourceIdstringPresent when the slot was selected inside a resource column.

EventCalendarState

The live state object, as received by useEventCalendarSelector selectors and returned by instance.getState(). Every field is a getter, so reading one subscribes to exactly that field.

PropertyTypeDescription
viewCalendarViewActive view ("month" | "week" | "day" | "days" | "agenda" | "resource").
dateDateAnchor date.
dayCountnumberDay count for the days view.
visibleRangeEventCalendarDateRangeFull rendered grid range including outside days; fetch remote data for this.
activeRangeEventCalendarDateRangeThe logical period (the month or week itself).
eventsCalendarEvent<TData>[]Current events.
selectionEventCalendarSelectionSelected occurrence keys plus the committed slot.
interactionsEventCalendarInteractionsEffective interaction toggles.
loadingbooleanThe loading prop.
dragEventCalendarDragState<TData> | nullLive move/resize gesture (kind, occurrence, proposed instants, validity), or null.
slotDraftEventCalendarSlotDraft | nullLive drag-create rectangle, or null.
viewSettingsEventCalendarViewSettingsUser display toggles.

EventCalendarDateRange is { start: Date; end: Date } with an inclusive start and an exclusive end. The exported EventCalendarDataAdapter type (getEvents(range, signal?)) describes the shape of an external data source; wiring it to a backend is application territory.

Hooks

Most hooks require an EventCalendar ancestor. The exceptions: useEventCalendarState creates the instance itself, useNow is standalone, useEventCalendarSettingsVersion takes the instance as an argument, and hooks that accept an explicit calendar option work outside the tree.

HookSignatureDescription
useEventCalendarState(options?: UseEventCalendarStateOptions<TData>) => EventCalendarInstance<TData>Headless root hook: the full engine without markup. options is read lazily, so passing a live props object keeps every controlled option reactive.
useEventCalendar() => EventCalendarInstance<TData>The stable calendar instance from context; throws outside EventCalendar.
useEventCalendarSelector(selector: (state: EventCalendarState<TData>) => T, options?: { calendar?, isEqual? }) => Accessor<T>A memo over the live state; isEqual becomes the memo comparator (referential equality by default).
useEventCalendarView() => { view, dayCount, availableViews, setView }Active view state plus the resolved views option.
useEventCalendarNavigation() => { date, title, visibleRange, activeRange, next, prev, today, goTo, isToday }Navigation state and actions; title is the formatted period title.
useEventCalendarSelection() => { selection, select, selectEvent, clearSelection }Selection state and actions.
useEventCalendarInteractions() => { interactions, setInteractions }Interaction toggles.
useEventCalendarOccurrences(range?: MaybeAccessor<EventCalendarDateRange>) => Accessor<EventCalendarOccurrence<TData>[]>Expanded, sorted occurrences; defaults to the visible range.
useEventCalendarDay(day: Date | (() => Date)) => { segments: { allDay, timed }, isToday, isOutside }Per-cell derived state; only cells whose segments changed recompute. isToday re-evaluates at the next midnight in the display zone.
useEventCalendarWeek(day: Date | (() => Date)) => { bars, laneCount, rowStart }Per-week-row derived state for the month view's laned multi-day bars.
useEventCalendarViewSettings() => { viewSettings, setViewSettings, effective }User display toggles plus the effective values after view-config fallback.
useEventCalendarSettings() => EventCalendarSettings<TData>Resolved settings including merged i18n; every field is a live getter.
useEventCalendarSettingsVersion(instance: EventCalendarInstance<TData>) => numberThe settings version counter. Vestigial in Solid; kept because the headless contract exposes it.
useEventCalendarViewConfig() => EventCalendarViewConfig<TData>Root-level display props and render overrides, for view components.
useEventCalendarViewContext() => { view: CalendarView }The rendering view of the nearest view component; throws outside a view.
useEventCalendarGestures() => { beginMove, beginResize, beginCreate, canDrag, canResize }Per-chip / per-surface pointer gesture wiring for custom views and cells. The begin* functions take a native PointerEvent.
useEventCalendarEventChip() => { occurrence, segment, isDragging, isSelected }The chip's subject; usable inside renderEvent content and chip children.
useNow(intervalMs?: number) => Accessor<Date>Current time, refreshed on an interval (default 30000 ms) and on tab focus.

An EventCalendarInstance is { getState, api, settings, internals }; internals is cross-file plumbing for sibling view modules and not public API. EventCalendarContext, EventCalendarViewConfigContext, and EventCalendarViewContext are exported for advanced composition.

Helpers

Pure, framework-free helpers exported from event-calendar-lib.tsx (plus the gesture flags from event-calendar-dnd.tsx). event-calendar-lib.tsx also exports the advanced types EventCalendarIndex, EventCalendarDayBucket, and EventCalendarWeekRow used in these signatures.

FunctionSignatureDescription
flattenResources(resources: EventCalendarResource[], depth?: number) => Array<{ resource, depth }>Depth-first flatten of the resource tree, parents included.
buildEventIndex(events, visibleRange, opts) => EventCalendarIndex<TData>Expand, segment, and pack all events for a range into { occurrences, byDay, weekRows }.
segmentOccurrence(occurrence, range, timeZone) => EventCalendarSegment<TData>[]The canonical multi-day segmentation (exclusive end, zero-duration safe).
packTimedSegments(segments) => voidGoogle-style overlap packing for one day's timed segments (mutates column fields in place).
packWeekRowLanes(segments, rowIndex, rowStart, timeZone) => EventCalendarSegment<TData>[]Greedy lane packing of bar segments within one week row; returns new merged bar segments.
defaultEventOrder(a, b: EventCalendarOccurrence) => numberDefault sort: earlier start, then longer duration, then key.
getViewDateRange(view, date, opts) => { visibleRange, activeRange }The rendered and logical ranges for a view and anchor date.
stepDate(view, date, direction, opts) => DateThe anchor date stepped one period forward or backward.
toZoned(date: Date, timeZone: string) => TZDateThe instant re-expressed in the display time zone.
zonedStartOfDay(date: Date, timeZone: string) => TZDateZoned midnight of the day containing the instant.
getDayKey(date: Date, timeZone: string) => stringStable per-day key (yyyy-MM-dd) in the display time zone.
getDayTotalMinutes(dayStart: Date, timeZone: string) => numberDay length in minutes; 1380/1500 on DST transition days.
getRangeKey(range: EventCalendarDateRange) => stringCheap string cache key for a range.
snapMinutes(minutes: number, snap: number) => numberRound minutes to the nearest snap step.
rangesIntersect(a, b: EventCalendarDateRange) => booleanHalf-open range intersection.
eventsOverlap(a, b: { start: Date; end: Date }) => booleanHalf-open overlap test.
isBarOccurrence(occurrence: EventCalendarOccurrence) => booleanTrue when the occurrence renders as a bar (all-day or multi-day).
spansMultipleDays(occ: { start: Date; end: Date }) => booleanLonger than 24 hours (an event ending exactly at midnight is single-day).
resolveOffDay(day, timeZone, config: boolean | EventCalendarOffDaysConfig | undefined) => booleanWhether a day is an off day in the display zone.
minuteBlockStyle(startMin, endMin, boundsStartMin) => JSX.CSSPropertiesAbsolute top/height for a minute-positioned overlay block (from event-calendar-time-grid.tsx).
wasRecentDrag() => booleanTrue shortly after a gesture ended; lets click handlers ignore the click that ends a drag.
wasRecentChipPress() => booleanTrue shortly after a press started on an event chip; guards slot-create clicks.
markChipPress() => voidFlag a chip press (called by EventCalendarEvent); needed when building custom chips.

Useful exported constants: BASE_VIEWS and ALL_VIEWS (the view lists), DEFAULT_VIEW_CONFIG, DEFAULT_VIEW_COMPONENTS (the view switchboard map), EVENT_CALENDAR_ACTIVATION (gesture defaults), EVENT_CALENDAR_COLORS (ten Tailwind palette presets for event.color), EVENT_CALENDAR_GHOST (the standardized drag-ghost classes), EVENT_CALENDAR_FADE_TRUNCATE (the fade-out truncation classes, reusable in renderEvent content), MIN_PACK_SLOT (packing-effective minimum of 30 minutes), and MAX_OCCURRENCES (recurrence expansion cap of 1000 per event).


Recurrence helpers

Exported from event-calendar-recurrence.tsx. The supported RRULE subset is FREQ (daily, weekly, monthly, yearly), INTERVAL, COUNT, UNTIL, BYDAY (with ordinals for monthly/yearly), BYMONTHDAY, BYMONTH, and WKST; anything else throws EventCalendarRecurrenceError, whose message points at the getOccurrences escape hatch.

FunctionSignatureDescription
expandRecurrence(event, range, ctx: { timeZone }) => EventCalendarOccurrence<TData>[]Expand one event into its occurrences intersecting the range; wall-time iteration in the display zone (DST-safe).
parseRRuleString(input: string, timeZone?: string) => EventCalendarRecurrenceRuleParse a raw RRULE line (with or without the prefix); Z-less UNTIL values are wall time in the given zone.
formatRRuleString(rule: EventCalendarRecurrenceRule) => stringSerialize the structured subset back to an RRULE line (without prefix).

Config

State options

Option props accepted by EventCalendar and useEventCalendarState. Controlled/uncontrolled pairs follow the standard convention: pass the controlled prop plus its on*Change callback, or the default* prop for internal state.

PropTypeDefaultDescription
events / defaultEventsCalendarEvent<TData>[][]The events (controlled / uncontrolled).
view / defaultViewCalendarView"month"Active view.
date / defaultDateDatenew Date()Anchor date.
dayCount / defaultDayCountnumber3Day count for the days view (min 1).
selection / defaultSelectionEventCalendarSelection{ eventKeys: [], slot: null }Selection state.
interactions / defaultInteractionsPartial<EventCalendarInteractions>all trueInteraction toggles, merged over the defaults.
viewSettings / defaultViewSettingsEventCalendarViewSettings{}User display toggles.
loadingbooleanfalseDims the content area and disables pointer events.
viewsCalendarView[]base viewsEnabled views; defaults to ["month", "week", "day", "days", "agenda"], plus "resource" when resources is non-empty.
timeZonestringsystem time zoneIANA display time zone; all day math and rendering happen in it.
localeLocale-date-fns locale for every formatted string.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6localeFirst day of the week (0 = Sunday). Defaults to the locale's own first day when one is set; an explicit value wins.
dayStartHournumber0First rendered hour in time grids.
dayEndHournumber24Last rendered hour in time grids.
slotDurationnumber30Duration in minutes of a click-created slot (onSlotClick end).
snapDurationnumber15Snap granularity in minutes for drag, resize, and drag-create.
agendaDayCountnumber30Days covered by the agenda view window.
fixedWeeksbooleantrueMonth view always renders 6 weeks.
showOutsideDaysbooleantrueShow leading/trailing outside days in the month view.
i18nEventCalendarI18nOverrides-Per-key overrides of labels, view names, formats, and formatter functions.
resourcesEventCalendarResource[][]Bookable resources for the resource view.
getEventPriority(event: CalendarEvent<TData>) => numberevent.priority ?? 0Packing prominence resolver.
eventOrder(a, b: EventCalendarOccurrence<TData>) => numberdefaultEventOrderOccurrence sort comparator (default: earlier start, then longer duration, then key).
getOccurrences(event, range, ctx: { timeZone }) => Array<{ start: Date; end: Date }> | null-Custom recurrence expansion per event (plug a full RRULE engine); return null for the built-in one.
weekendDaysnumber[][0, 6]Weekday numbers treated as the weekend by the "weekends" view toggle.
activationPartial<EventCalendarActivationConfig>see belowPointer-gesture tuning.

EventCalendarActivationConfig defaults: moveDistancePx: 5, createDistancePx: 4, touchDelayMs: 250, touchTolerancePx: 5, autoScrollEdgePx: 48, autoScrollMaxStepPx: 15 (exported as EVENT_CALENDAR_ACTIVATION).


Callbacks

Callback props accepted by EventCalendar and useEventCalendarState, alongside the options above. Event arguments are native DOM events.

PropSignatureDescription
onEventClick(occurrence, e: MouseEvent) => voidChip click; call e.preventDefault() to opt out of the built-in selection.
onEventDoubleClick(occurrence, e: MouseEvent) => voidChip double click.
onEventUpdate(update: EventCalendarProposedUpdate<TData>) => EventCalendarUpdateResultThe one validation funnel for drags, resizes, and API timing changes; false rejects, an object adjusts.
canDropEvent(update: EventCalendarProposedUpdate<TData>) => booleanLive validation during a gesture; drives the data-drop-invalid styling and the not-allowed cursor.
onDragBlocked(occurrence, info: { gesture: "move" | "resize"; reason: "readOnly" | "disabled" | "interactions-off" }) => voidA gesture was attempted on a locked event; fires once per gesture so you can surface a message.
onSlotClick(slot: EventCalendarSlotInfo, e: MouseEvent) => voidClick on an empty slot or day cell (also fired by the day add button).
onSelectSlot(slot: EventCalendarSlotDraft) => voidA drag-create selection was committed.
canSelectSlot(slot: EventCalendarSlotDraft) => booleanLive validation of the drag-create rectangle.
onRangeChange(info: EventCalendarRangeInfo) => voidVisible range changed (view, date, or time zone); fires once for the initial range. info carries range, activeRange, view, date, and timeZone.
onViewChange(view: CalendarView) => voidView changed.
onDateChange(date: Date) => voidAnchor date changed.
onDayCountChange(count: number) => voidN-day count changed.
onSelectionChange(selection: EventCalendarSelection) => voidSelection changed. This calendar callback replaces the DOM handler of the same name on the root.
onInteractionsChange(interactions: EventCalendarInteractions) => voidInteraction toggles changed.
onViewSettingsChange(viewSettings: EventCalendarViewSettings) => voidUser display toggles changed.
onEventsChange(events: CalendarEvent<TData>[]) => voidEvents array changed (drag commit, resize, API mutation).
onMoreClick(day: Date, occurrences: EventCalendarOccurrence<TData>[], e: MouseEvent) => void | false"+N more" clicked; return false to suppress the built-in popover and open your own UI.

View configuration

Display props and render overrides, accepted directly on EventCalendar (they live in the view layer, never in the headless options). Defaults come from DEFAULT_VIEW_CONFIG. Passing undefined keeps the default rather than erasing it.

PropTypeDefaultDescription
scrollToHournumber7Hour the time grids scroll to on mount.
nowIndicatorbooleantrueShow the current-time line in time grids.
intervalnumber60Grid interval in minutes for time-based views; gutter slots and gridlines follow it. Also accepted per view component.
maxEventsPerCellnumber | "auto""auto"Max bars plus chips per month cell before "+N more".
showWeekNumbersbooleanfalseWeek-number gutter in the month view.
enableShortcutsbooleantrueView-switcher keyboard shortcuts and their kbd hints.
shortcutsScope"focus-within" | "global""focus-within"Where the shortcuts listen.
scrollMode"contained" | "page""contained"Contained: the calendar fills its container and views scroll internally. Page: content flows with the document and day headers stick below --ec-sticky-offset.
stickyNavbooleanfalseStick the default nav to the top while the page scrolls.
dayClassName(day: Date) => string | undefined-Custom per-day indication on month cells, day columns, and all-day cells.
todayClassNamestring-Extra classes for the current day, appended after the built-in highlight.
showDayAddButtonbooleanfalseHover "+" affordance on month cells; fires the same onSlotClick as clicking the day.
scrollbars"custom" | "native""custom"Scroll implementation for every internally scrolling surface (the Zaidan Scroll Area vs browser scrollbars).
navButtonVariant"ghost" | "outline" | "secondary" | "default""ghost"Variant applied to all nav buttons.
navButtonSize"sm" | "default""sm"Size applied to all nav buttons (icon buttons use the icon twin).
offDaysboolean | EventCalendarOffDaysConfig-Off-day (non-working day) marking; true = weekends with a muted background.
classNamesEventCalendarClassNames-Per-element class hooks; see below.
componentsPartial<Record<CalendarView, Component>>-Swap individual view implementations.
dayCountPresetsnumber[][5]N-day presets offered by the view switcher when the days view is enabled.
navTooltipsfalse | { side?, delay?, closeDelay?, timeout? }{ side: "bottom", delay: 600, closeDelay: 0, timeout: 300 }Nav tooltips: false disables them all; the object tunes placement and timings.
eventTooltipboolean | { side?, delay? }falseStyled tooltip on event hover / focus; true shows the event label, an object tunes side and delay. Content via renderEventTooltip.
compactEventMinutesnumber45Timed events shorter than this render the compact single-row chip layout.
morePopoverAlign"start" | "center" | "end""start""+N more" popover alignment against its trigger (mapped onto the Kobalte Popover placement).
nowIndicatorIntervalnumber30000Now-indicator refresh cadence in milliseconds.
agendaSummaryMaxDotsnumber6Max color dots in a collapsed agenda day summary.

Render overrides, all optional and all part of the same view config. Each returns a JSX.Element.

PropSignatureDescription
renderEvent(props: EventCalendarRenderEventProps<TData>) => JSX.ElementReplace the chip content in grid views; receives occurrence, segment, view, isDragging, isSelected.
renderAgendaEvent(props: EventCalendarRenderEventProps<TData>) => JSX.ElementReplace the agenda row content.
renderEventTooltip(props: { occurrence, segment, view, label }) => JSX.ElementContent for the styled hover tooltip (eventTooltip); a falsy return falls back to the default label.
renderDragPreview(props: { drag: EventCalendarDragState<TData> }) => JSX.ElementCustom drag preview content.
renderMonthCell(props: { day, segments, isToday, isOutside, overflowCount, defaultContent }) => JSX.ElementReplace a month cell's body. defaultContent is a lazy, cached getter, so wrapping costs nothing when unused.
renderDayColumnBackground(props: { day, boundsStartMin, boundsEndMin, totalMinutes }) => JSX.ElementBusiness-logic layer rendered pointer-events-none behind event segments in each day column.
renderDayHeader(props: { day, view, isToday }) => JSX.ElementReplace day header cells (month header row and time-grid headers).
renderTimeGutterSlot(props: { time, hour, minute }) => JSX.ElementReplace hour gutter labels.
renderAllDaySection(props: { days, segments }) => JSX.ElementReplace the entire all-day row of time grids.
renderMoreIndicator(props: { day, count, segments }) => JSX.ElementReplace the "+N more" trigger content.
renderMoreContent(props: { day, segments, close }) => JSX.ElementReplace the entire body of the built-in "+N more" popover while keeping its trigger and positioning.
renderNowIndicator(props: { time: Date }) => JSX.ElementReplace the current-time line.
renderNoEvents() => JSX.ElementReplace the agenda empty state.
renderResourceHeader(props: { resource: EventCalendarResource }) => JSX.ElementResource column header content; default is resource.title.

EventCalendarViewConfig also declares renderAgendaEventDetails, renderAgendaDayHeader, and renderAgendaDaySummary, plus the agendaDay, agendaDayGutter, agendaDate, agendaDayToggle, agendaDayContent, agendaItemSurface, agendaItemToggle, agendaDaySummary, and agendaSummaryDot class keys. They are part of the type surface, but the shipped agenda view does not render collapsible days or expandable details, so nothing reads them today.

The classNames object (EventCalendarClassNames) offers one string hook per element, cn-merged after the built-in classes so Tailwind variants and ! overrides win. Available keys: nav, toolbar, content, monthView, monthCell, timeGrid, timeGutter, dayColumn, allDaySection, agendaView, event, eventTooltip, moreIndicator, morePopover, morePopoverHeader; nav family navButton, title, navTooltip, viewSwitcherContent, viewSwitcherLabel, viewShortcut, datePickerContent; month view monthHeader, monthDayHeader, monthBody, monthRow, weekNumber, monthBarOverlay, monthBar, monthCellContent, monthCellFooter, monthDayNumber, dayAddButton; time grid and resource timeGridHeader, timeGutterLabel, allDayLabel, allDayCell, timedChip, resourceHeader; interaction surfaces dragGhost, dragCarry, dragCarryInvalid, dropHint, dropIndicator, slotDraft, resizeHandle, resizeGrip; agenda noEvents, agendaDayHeader, agendaItem (plus the unused agenda keys listed above). Metric CSS variables can ride on any parent key, for example classNames.timeGrid: "[--ec-gutter-width:4rem]" or classNames.morePopover: "[--ec-more-max-height:20rem]".


EventCalendarInteractions

Interaction toggles, controllable via the interactions prop or api.setInteractions. All three default to true.

PropertyTypeDefaultDescription
dragbooleantrueMove events by dragging.
resizebooleantrueResize events at their edges.
selectSlotbooleantrueDrag-create slot selection.

EventCalendarViewSettings

User-adjustable display toggles (the "view settings" state), controllable via viewSettings / onViewSettingsChange or api.setViewSettings. Every field is optional; undefined defers to the matching view-config prop (see useEventCalendarViewSettings().effective).

PropertyTypeDefaultDescription
weekendsboolean-Show Saturday/Sunday columns in month, week, and N-day grids (effective default true).
weekNumbersboolean-Week-number gutter in the month view (defers to showWeekNumbers).
nowIndicatorboolean-Current-time line (defers to the nowIndicator view config).
offDaysboolean-Off-day background marking (defers to the offDays view config).

EventCalendarOffDaysConfig

Configuration for non-working-day marking, passed as the offDays view config. true uses the defaults (weekends with a muted background); marked cells carry data-off for CSS-selector customization.

PropertyTypeDefaultDescription
weekendDaysnumber[][0, 6]Weekday numbers treated as off (0 = Sunday).
datesDate[]-Additional explicit off dates (compared by day in the display zone).
isOffDay(day: Date) => boolean-Full custom predicate; runs in addition to weekendDays / dates.
classstring"bg-muted/40"Marker classes. Named class here; the React original calls it className.

Internationalization

The i18n option accepts an EventCalendarI18nOverrides object with four sections, shallow-merged per nested object so a partial override replaces individual keys, never whole sections:

  • labels: UI strings and label functions (today, previous, next, addEvent, allDay, more(count), noEvents, loading, event, events(count), selectView, week(weekNumber), resources, goToDate, dropNotAllowed, continues, timeFrom(time), timeUntil(time), viewShortcuts, toggleDayEvents(count, expanded), eventDetails(title), moreCompact(count), timeRange(from, to)).
  • viewNames: display names per view (month, week, day, days(count), agenda, resource).
  • formats: date-fns format strings applied with the calendar locale (monthTitle, weekTitle, dayTitle, agendaTitle, monthDayHeader, monthDayHeaderNarrow, timeGridDayHeader, agendaDayHeader, agendaDayNumber, agendaWeekday, moreDayHeader, monthCellAriaLabel, dayAria, resourceTitle, timeGutter, timeGutterMinute, eventTime, monthCellDay). Leaving weekTitle / agendaTitle undefined keeps the smart cross-month range title.
  • functions: formatter functions (formatTitle, formatEventTime, formatDayRange, formatEventLabel, formatEventAriaLabel). The defaults are re-bound to the merged labels and formats, so overriding formats.monthTitle flows into the default formatTitle without replacing it.

mergeEventCalendarI18n(overrides?) performs this merge and DEFAULT_EVENT_CALENDAR_I18N holds the defaults; both are exported from event-calendar-i18n.tsx for standalone use.

Accessibility

  • The month view is an ARIA grid: week rows are row, day cells are gridcell, and the header cells are columnheader; the week-number gutter uses rowheader.
  • Event chips are real buttons with an aria label built from i18n.functions.formatEventAriaLabel, so the whole grid is tab and arrow navigable, and the month view restores chip focus after a drag recommits the lanes.
  • The root renders an aria-live="polite" announcer (data-slot="event-calendar-announcer") that gesture code writes into, and EventCalendarTitle is itself aria-live="polite" so the period is announced on navigation.
  • View-switcher shortcuts are on by default and scoped to focus-within, so they never hijack keys typed elsewhere on the page; set shortcutsScope="global" or enableShortcuts={false} to change that.
  • Nav tooltips open on hover and keyboard focus-visible only, and are force-closed while the overlay they belong to is open.

On This Page

  • Installation
  • Usage
  • Solid API notes
  • API Reference
    • EventCalendar
    • EventCalendarNav
    • EventCalendarNavToday
    • EventCalendarNavPrev
    • EventCalendarNavNext
    • EventCalendarTitle
    • EventCalendarViewSwitcher
    • EventCalendarDatePicker
    • EventCalendarToolbar
    • EventCalendarContent
    • EventCalendarMonthView
    • EventCalendarTimeGrid
    • EventCalendarWeekView
    • EventCalendarDayView
    • EventCalendarDaysView
    • EventCalendarAgendaView
    • EventCalendarResourceView
    • EventCalendarEvent
    • EventCalendarApi
    • CalendarEvent
    • EventCalendarResource
    • EventCalendarRecurrenceRule
    • EventCalendarOccurrence
    • EventCalendarSegment
    • EventCalendarProposedUpdate
    • EventCalendarSlotInfo
    • EventCalendarSlotDraft
    • EventCalendarState
  • Hooks
  • Helpers
    • Recurrence helpers
  • Config
    • State options
    • Callbacks
    • View configuration
    • EventCalendarInteractions
    • EventCalendarViewSettings
    • EventCalendarOffDaysConfig
    • Internationalization
  • Accessibility
Built by Kevin Abatan. The source code is available on GitHub.