| name | stimulus-calendar-js |
| description | Use stimulus_calendar, an HTML-first event calendar for Stimulus.js (Hotwire). Apply when adding or editing an interactive calendar/scheduler in a Stimulus/Hotwire (non-Rails-specific) front end — month/week/day/list/resource/timeline views, drag & drop, event sources, broadcasting changes between tabs over BroadcastChannel/WebSocket. For the Rails server-driven version (Turbo Stream live sync, server-side event sources, optimistic edits with reconcile) use the stimulus-calendar-rails skill instead. |
Using stimulus_calendar (the JS library)
stimulus_calendar is a client-side event calendar built from Stimulus
controllers. The HTML is the configuration — drop data-controller="calendar"
on a <div>, pass options via data-* attributes, and the controllers
enhance it. A 100% Stimulus port of vkurko/calendar
(Svelte 5; v5.7.1).
Status — early. This skill grows with the library. Sections marked
(Phase N) describe behaviour that lands as the matching PLAN.md phase
ships. Always check the linked PLAN.md for the current
per-feature checklist.
Setup (pick one)
Plain script (no bundler): the IIFE bundle includes Stimulus.
<link rel="stylesheet" href="/path/dist/stimulus_calendar.css" />
<script src="/path/dist/stimulus_calendar.js"></script>
<script>StimulusCalendar.start()</script>
ES module / importmap: the ESM bundle externalizes @hotwired/stimulus.
import { Application } from "@hotwired/stimulus"
import StimulusCalendar from "stimulus_calendar"
const app = Application.start()
StimulusCalendar.start(app)
StimulusCalendar.start(app?) registers the controllers (calendar, plus per-
view satellites as they ship) and returns the Application. Call it once.
Minimal calendar
<div data-controller="calendar"
data-calendar-view-value="timeGridWeek"
data-calendar-plugins-value='["TimeGrid", "Interaction"]'
data-calendar-options-value='{
"events": [
{ "id": "1", "title": "Standup", "start": "2026-05-25T09:00", "end": "2026-05-25T09:30" },
{ "id": "2", "title": "Design crit", "start": "2026-05-26T14:00", "end": "2026-05-26T15:00" }
],
"headerToolbar": { "start": "prev,next today", "center": "title", "end": "dayGridMonth,timeGridWeek,timeGridDay" }
}'
style="height: 600px"></div>
The calendar needs a height: set style="height:…" (or a CSS class), or use
data-calendar-height-value="auto".
Three ways to provide events
- Inline — pass the array under
options.events in
data-calendar-options-value (server-rendered JSON works).
- URL —
data-calendar-event-source-value="/events.json" returns an
array of events; refetched on view-range changes when lazyFetching is on.
- JS — after
calendar:ready:
el.calendarApi.addEvent({ id, title, start, end }).
Each event needs a stable string id.
Calendar attributes (on the data-controller="calendar" element)
Every option below ships built in — never tell users to look at upstream
docs. Bundle options under data-calendar-options-value as a JSON object
or, equivalently, pass each one as data-calendar-<kebab>-value.
Core (always available)
view (string) · views (object — per-view overrides)
plugins (string[]) · date (Date|ISO) · duration (Duration)
dateIncrement (Duration) · firstDay (0–6) · hiddenDays (number[])
validRange ({start, end}) · height (string|number)
theme (object|fn) · locale (string|object) · timeZone ("local"|"UTC"|IANA)
customScrollbars (bool) · viewDidMount (fn) · datesSet (fn)
loading (fn) · lazyFetching (bool) · highlightedDates ((Date|string)[])
titleFormat / dayHeaderFormat / dayHeaderAriaLabelFormat (Intl format)
icons (object|fn) · buttonText (object|fn) · customButtons (object)
headerToolbar ({start, center, end})
events ([]) · eventSources ([]) · eventFilter (fn) · eventOrder (fn|string|string[])
eventColor / eventBackgroundColor / eventTextColor (string)
eventClassNames (string|string[]|fn) · eventContent (fn|string|{html}|{domNodes})
eventDidMount (fn) · eventTimeFormat (Intl format) · displayEventEnd (bool)
eventClick / eventMouseEnter / eventMouseLeave / eventAllUpdated (fn)
selectable (bool)
DayGrid plugin — dayGridDay / dayGridWeek / dayGridMonth
dayCellFormat (Intl format) · dayCellContent (fn|string|{html})
dayMaxEvents (bool|number) · moreLinkContent (fn|string)
dayPopoverFormat (Intl format)
weekNumbers (bool) · weekNumberContent (fn|string)
TimeGrid plugin — timeGridDay / timeGridWeek
slotDuration / slotMinTime / slotMaxTime / slotLabelInterval / scrollTime (Duration)
slotHeight (number px) · slotLabelFormat (Intl format)
flexibleSlotTimeLimits (bool|object) · nowIndicator (bool) · slotEventOverlap (bool)
allDaySlot (bool) · allDayContent (fn|string|{html})
columnWidth (number px) · snapDuration (Duration)
List plugin — listDay / listWeek / listMonth / listYear
listDayFormat / listDaySideFormat (Intl format)
noEventsContent (string|fn|{html}) · noEventsClick (fn)
Resource + ResourceTimeGrid — resourceTimeGridDay / …Week
resources (Resource[]|fn|EventSource-shape) · refetchResourcesOnNavigate (bool)
datesAboveResources (bool)
resourceLabelContent (fn|string) · resourceLabelDidMount (fn)
filterResourcesWithEvents / filterEventsWithResources (bool)
Inherits every TimeGrid option.
ResourceTimeline — resourceTimelineDay / …Week / …Month / …Year
slotWidth (number px) · monthHeaderFormat (Intl format)
resourceExpand (bool|'all'|number)
Inherits every TimeGrid + Resource option.
Interaction plugin — drag / drop / resize / select / click
pointer (bool) · dateClick (fn)
editable (bool) · eventStartEditable (bool) · eventDurationEditable (bool)
eventResizableFromStart (bool)
eventDragStart / eventDragStop / eventDrop (fn)
eventResizeStart / eventResizeStop / eventResize (fn)
eventDragMinDistance (px) · eventLongPressDelay (ms)
dragConstraint (object) · dragScroll (bool) · snapDuration (Duration)
resizeConstraint (object)
selectable (bool) · select / unselect (fn)
unselectAuto (bool) · unselectCancel (CSS selector)
selectBackgroundColor (string) · selectConstraint (object)
selectMinDistance (px) · selectLongPressDelay (ms) · longPressDelay (ms)
stimulus_calendar extensions (not in any upstream calendar lib)
data-calendar-event-source-value URL → one URL event source
data-calendar-resource-source-value URL → one URL resource source
data-calendar-broadcast-value 'turbo-stream' | 'action-cable' | 'websocket' | 'broadcast-channel' | false
data-calendar-broadcast-channel-value channel name (BroadcastChannel) or URL (WS/AC)
Event object shape (events, eventSources, addEvent, broadcasts)
id (string — required for updates/removes)
resourceIds (string[] — Resource views)
allDay (bool — inferred from noTimePart(start) if omitted)
start (Date|ISO — required) · end (Date|ISO — optional)
title (string) · display ('auto' | 'background')
backgroundColor / textColor / color (string)
classNames (string|string[])
editable / startEditable / durationEditable (bool — per-event overrides)
extendedProps (object — passed through to callbacks unchanged)
Resource object shape (resources)
id (string — required) · title (string)
children (Resource[] — nested, rendered as a tree)
eventBackgroundColor / eventTextColor (string — defaults for events on this resource)
extendedProps (object)
calendarApi (on element.calendarApi, ready after calendar:ready)
Every method below is built in. None require external docs.
const api = document.querySelector('[data-controller~="calendar"]').calendarApi
api.addEvent({ id: "1", title: "Lunch", start: "2026-05-25T12:00", end: "2026-05-25T13:00" })
api.updateEvent({ id: "1", start: "2026-05-25T13:00", end: "2026-05-25T14:00" })
api.removeEventById("1")
api.getEvents()
api.getEventById("1")
api.refetchEvents()
api.refetchResources()
api.getResources()
api.next(); api.prev(); api.today()
api.gotoDate("2026-06-15")
api.getView()
api.dateFromPoint(x, y)
api.setOption("view", "timeGridDay")
api.getOption("locale")
api.unselect()
StimulusCalendar.create(el, { view: "timeGridWeek", events: [...] })
StimulusCalendar.destroy(el)
Events (on the calendar element)
Every event listed here is built in. The matching data-calendar-<event>-value
callback option fires for the same event — pick whichever is more ergonomic.
All events bubble.
| Event | detail shape | Fires when |
|---|
calendar:ready | { api } | Controller mounted; element.calendarApi callable. |
calendar:datesSet | { start, end, view } | Active date range changed. |
calendar:viewDidMount | { view, el } | A view's DOM first created. |
calendar:eventClick | { event, jsEvent, view } | User clicked an event. |
calendar:eventMouseEnter | { event, jsEvent, view } | Hover-in. |
calendar:eventMouseLeave | { event, jsEvent, view } | Hover-out. |
calendar:eventDidMount | { event, el, view } | Event DOM first inserted. |
calendar:eventAllUpdated | {} | After every event re-render pass. |
calendar:dateClick | { date, allDay, jsEvent, view, resource? } | Click on an empty cell. |
calendar:eventDragStart | { event, jsEvent, view } | Drag begins. |
calendar:eventDragStop | { event, jsEvent, view } | Drag ends (before drop applies). |
calendar:eventDrop | { event, oldEvent, delta, jsEvent, view, revert } | Drop applied — call revert() to undo. |
calendar:eventResizeStart | { event, jsEvent, view } | Resize begins. |
calendar:eventResizeStop | { event, jsEvent, view } | Resize ends (before commit). |
calendar:eventResize | { event, oldEvent, endDelta, jsEvent, view, revert } | Resize commit — call revert() to undo. |
calendar:select | { start, end, allDay, jsEvent, view, resource? } | Drag-selection commits. |
calendar:unselect | { jsEvent } | Selection cleared. |
calendar:loading | { isLoading } | Event source starts/stops a fetch. |
calendar:broadcast:out | { message } | Local mutation about to publish. |
calendar:broadcast:in | { message } | Broadcast arrived from another tab/user. |
calendar.addEventListener("calendar:ready", (e) => e.detail.api.refetchEvents())
calendar.addEventListener("calendar:eventDrop", (e) => save(e.detail).catch(e.detail.revert))
Plugins
The calendar's behaviour is composed from named plugins. Pick the ones you
need; the controller lazily activates only the requested set.
- DayGrid —
dayGridMonth, dayGridWeek, dayGridDay views.
- TimeGrid —
timeGridWeek, timeGridDay with hour slots.
- List —
listDay, listWeek, listMonth, listYear chronological list.
- Resource + ResourceTimeGrid —
resourceTimeGridDay, resourceTimeGridWeek.
- ResourceTimeline —
resourceTimelineDay/Week/Month/Year.
- Interaction — pointer, dateClick, drag/drop/resize, selection.
Pass them in data-calendar-plugins-value as a JSON array of names.
Broadcasting changes between tabs / users (Phase 13)
When data-calendar-broadcast-value is set, every local mutation (add /
update / remove via calendarApi, drag, resize, or dateClick+create) is
serialised as a JSON message and published to the chosen adapter. Inbound
messages from the same adapter are applied locally — origin-tagged to suppress
echo loops.
<div data-controller="calendar"
data-calendar-broadcast-value="broadcast-channel"
data-calendar-broadcast-channel-value="team-cal"
... ></div>
| Adapter | Use case |
|---|
broadcast-channel | Tab-to-tab sync within one browser; no server. |
websocket | Raw WebSocket; you define the server format. |
action-cable | Subscribe an Action Cable channel directly. |
turbo-stream | <turbo-stream action="calendar-event-…"> custom actions over Action Cable; pair with the stimulus_calendar_rails gem. |
Payload schema:
{
"op": "add" | "update" | "remove" | "refetch",
"event": { "id": "...", "title": "...", "start": "...", "end": "...", ... },
"meta": { "channel": "...", "user": "..." },
"origin": "<random nonce — used to suppress echo>"
}
See docs/BROADCAST.md for the full wire
format. If the app is Rails + Hotwire, switch to the
stimulus-calendar-rails skill
and read docs/LIVE_SYNC_RAILS.md —
the Rails companion gem handles model commit hooks, optimistic-id echo
suppression, multi-tenant scoping, and the matching Turbo Stream
custom action so you don't hand-roll any of it.
Gotchas
- The calendar manages its own internal DOM (re-renders cells/events on view
change, scroll). Don't mutate event
<div>s directly — change data via
calendarApi and use eventContent/dayCellContent callbacks.
- Event id type matters for
updateEvent/removeEventById — keep ids
consistent (strings recommended).
- Set a height on the calendar element or scrollable views (TimeGrid, list)
have nothing to scroll inside.
- When
lazyFetching: true, an event source URL is re-hit on every view-range
change; debounce / cache on your server.
- For multi-tab broadcasting without a server, use
"broadcast-channel" —
it's the simplest adapter and works offline.