Calendar

Stable v1.0.2

A headless-and-turnkey calendar system with month, week, day, agenda, and year views, recurring events, timezone-aware rendering, and pluggable third-party sources — all themed off the existing tokens.

Interactive Playground

Switch views, resize, change the week start and timezone, and toggle the business-hours highlight. Every control maps to a real prop.

View & Density
Month
Medium (md)
Locale & Zone
Sunday
Local (browser)
Time Grid
Behavior
Local Data States

This demo always has an in-memory provider, so these props (for a purely local-data consumer) never fire on their own — toggle them to see the built-in spinner/error UI.

Drag an event to move it, drag its edge to resize, drag empty space to create — or click an event for its popover.

July 2026

Month
Sun
Mon
Tue
Wed
Thu
Fri
Sat
28
29
30
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
9:30 AMTeam Standup
11:00 AMDesign Review
30
12:00 PMLunch & Learn
31
2:00 PMWeekly Sync
3:00 PM1:1 with Sam
1
Offsite (Berlin)
Product Launch
Live Source Code
<Calendar
  :events="events"
  :day-start-hour="7"
  :day-end-hour="20"
/>

Recurring Events

Attach a recurrence rule and the engine expands the series across whatever range is visible — no rrule dependency. Supports daily / weekly / monthly / yearly with interval, count, until, byWeekday, byMonthday, and exceptions.

Recurrence rule
const events = [
  {
    id: 'sync',
    title: 'Weekly Sync',
    start: new Date('2026-07-22T14:00'),
    end: new Date('2026-07-22T15:00'),
    severity: 'primary',
    // Hand-rolled RRULE subset — no extra dependency.
    recurrence: {
      freq: 'weekly',
      interval: 1,
      byWeekday: ['mo', 'we', 'fr'],
      count: 12
    }
  }
]

Headless Mode

useCalendar() exposes all state, navigation, and event/recurrence logic with zero rendering, so you can build a fully custom layout. The <Calendar /> component is just the default view over it.

useCalendar()
<script setup>
import { useCalendar } from '@midnight-owl/ui'

// Zero rendering — build your own layout on top of the engine.
const cal = useCalendar({
  view: 'month',
  events: myEvents,          // ref, getter, or array
  firstDayOfWeek: 1,
  timeZone: 'Europe/London'
})
// cal.monthWeeks, cal.dayColumns, cal.agendaDays, cal.title,
// cal.next(), cal.prev(), cal.today(), cal.setView('week'), ...
</script>

<template>
  <button @click="cal.prev()">Back</button>
  <h2>{{ cal.title.value }}</h2>
  <button @click="cal.next()">Next</button>

  <div v-for="week in cal.monthWeeks.value" :key="week.key" class="grid grid-cols-7">
    <div v-for="cell in week.days" :key="cell.key">
      {{ cell.date.getDate() }} — {{ cell.events.length }} events
    </div>
  </div>
</template>

Provider Adapters

A third-party source is a pluggable CalendarProvider, not a hardcoded fetch. The ICS adapter is fully offline; the Google adapter uses the same interface (you supply the OAuth token). Outlook / CalDAV can be added later without touching the component.

Adapters
import { createIcsAdapter, createGoogleAdapter } from '@midnight-owl/ui'

// 1. ICS — a subscription feed or a pasted .ics file. No OAuth, no SDK.
const ics = createIcsAdapter({
  name: 'Holidays',
  source: () => fetch('/calendars/holidays.ics').then((r) => r.text())
})

// 2. Google Calendar — same interface; you supply the access token.
const google = createGoogleAdapter({
  getAccessToken: () => getMyOAuthToken(),   // your flow
  calendarId: 'primary'
})

// Drop either into the component (or useCalendar) as the source:
// <Calendar :provider="ics" />
ICS import / export
import { parseIcs, generateIcs } from '@midnight-owl/ui'

// Import: .ics text -> CalendarEvent[]
const events = parseIcs(icsFileContents)

// Export: CalendarEvent[] -> downloadable .ics text
const icsText = generateIcs(events, { calName: 'My Calendar' })

Scheduling & CRUD

The playground above is fully wired: drag an event to move it, drag its edge to resize, drag empty space to open a pre-filled create modal, and click an event for its popover with Edit / Delete. Every mutation is emitted (never mutating the source event) and here routed through an in-memory CalendarProvider — the same interface Google Calendar uses.

Wiring
<Calendar
  :events="events"
  editable
  @range-select="openCreate"       // drag empty space -> create modal
  @event-edit="openEdit"           // popover "Edit" -> edit modal
  @event-delete="onDelete"         // popover "Delete"
  @event-drag="persist"            // drag to move
  @event-resize="persist"          // drag edge to resize
/>

// Every mutation flows through the CalendarProvider interface — the same
// contract the ICS and Google adapters implement. This demo's provider is an
// in-memory store; swap it for Google and nothing above changes.
const provider = {
  name: 'In-memory',
  fetchEvents: () => events.value,
  createEvent: (e) => (events.value = [...events.value, e]),
  updateEvent: (e) => (events.value = events.value.map(x => x.id === e.id ? e : x)),
  deleteEvent: (id) => (events.value = events.value.filter(x => x.id !== id))
}

Custom Slots

Swap individual pieces — the event pill, a day cell, or the whole toolbar — while keeping the default component. Drop to headless mode only when you need a completely different layout.

Slot overrides
<Calendar :events="events">
  <!-- Swap just the event pill; keep the default month/week/day layout -->
  <template #event="{ event, timeLabel }">
    <div class="flex items-center gap-1 rounded bg-primary/10 px-1 text-primary">
      <span class="font-semibold">{{ timeLabel }}</span>
      <span class="truncate">{{ event.title }}</span>
    </div>
  </template>

  <!-- Or replace a whole month day cell -->
  <template #dayCell="{ cell }">
    <span :class="cell.isToday ? 'font-bold text-primary' : ''">
      {{ cell.date.getDate() }}
    </span>
  </template>
</Calendar>

API Reference

Props, events, and slots for the turnkey component.

Props

Name
Type
Default
Description
events
CalendarEvent[]
[]
Source events. Recurring series are expanded automatically across the visible range.
view
'month' | 'week' | 'day' | 'agenda' | 'year'
'month'
Active view. Supports v-model:view via update:view.
date
Date | string
today
Focal date. Emits update:date on navigation.
size
'xs' | 'sm' | 'md' | 'lg' | 'xl'
'md'
Density scale for cell heights and text.
firstDayOfWeek
0–6
0
0 = Sunday … 6 = Saturday.
timeZone
string
undefined
IANA zone for rendering event instants. Falls back to local time.
locale
date-fns Locale
en-US
Locale for day and month names.
provider
CalendarProvider
undefined
Optional pluggable source (ICS, Google, …). Events are fetched per visible range.
dayStartHour
number
0
First hour shown in the week/day time grid.
dayEndHour
number
24
Last hour shown in the week/day time grid.
agendaDayCount
number
30
Number of forward days the agenda view lists.
businessHours
BusinessHours | boolean
undefined
Highlight working hours. true = Mon–Fri 09:00–17:00.
availableViews
CalendarView[]
all five
Which view buttons the toolbar shows.
showToolbar
boolean
true
Render the built-in nav + title + view switcher.
showNav
boolean
true
Show the toolbar's prev/next navigation buttons.
showToday
boolean
true
Show the toolbar's Today button.
showTitle
boolean
true
Show the toolbar's period title.
mobileFallback
boolean
true
Below sm, present month as agenda and week as day.
editable
boolean
true
Master switch for drag-to-move, drag-to-resize, and popover edit/delete.
showPopover
boolean
true
Open the built-in event popover on click (else just emit event-click).
printable
boolean
true
Show the toolbar's print button (calls window.print() with print styles).
loading
boolean
false
Show the built-in loading spinner. ORed with a provider fetch's own isLoading — for a local-data consumer with no provider.
error
string | Error | null
null
Show the built-in error banner. ORed with a provider fetch's own error.
keyboardShortcuts
boolean
true
T = today, arrows = prev/next, M/W/D = view. Scoped to whichever <Calendar> instance currently has focus.

Events

Event
Payload
Description
update:view
CalendarView
Fires on any view change (v-model:view).
update:date
Date
Fires on navigation (v-model:date).
event-click
(event, MouseEvent)
An event was clicked. Receives the normalized occurrence.
date-click
Date
An empty day / time slot was clicked.
view-change
CalendarView
Convenience alias of update:view.
event-drag
(event, { start, end })
Drag-to-move finished. Carries the new start/end; source is never mutated.
event-resize
(event, { start, end })
Drag-to-resize finished. Carries the new start/end.
range-select
{ start, end, allDay }
A span was swept out on empty space (drag-to-create).
event-edit
NormalizedEvent
The popover "Edit" action fired.
event-delete
NormalizedEvent
The popover "Delete" action fired.
retry
-
Fired alongside the built-in Retry button's own cal.refresh() — a local-data consumer's hook to trigger their own refetch.

Slots

Slot
Scope
Description
toolbar
{ cal, title, view, next, prev, today, setView }
Replace the entire toolbar; the composable is handed through.
event
{ event, timeLabel, display }
Replace the event pill in every view without going headless.
dayCell
{ cell }
Replace a month day cell (date number + its events).
eventPopover
{ event, close, edit, delete }
Replace the popover body while keeping its positioning / mobile sheet.
empty
-
Shown by the agenda view when nothing is scheduled.