Phoenix components for EventCalendar (v5.17.2): a full-sized, drag & drop event calendar with day, week, month, list, resource and timeline views.
<.calendar>for LiveView: clicks, drag & drop, resizing and selections are pushed to the server,Phoenix.LiveView.JScommands are supported and the server can drive the calendar.<.static_calendar>for regular controllers, initialized with plain JavaScript.- Every EventCalendar option can be passed from Elixir.
- The JavaScript and CSS ship prebuilt with the package, no npm install needed.
Installation
Add calendar_component to your dependencies in mix.exs:
def deps do
[
{:calendar_component, "~> 0.3.0"}
]
endJavaScript
Register the hook in assets/js/app.js:
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import CalendarHooks from "calendar_component"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...CalendarHooks} // merge with your other hooks: {...CalendarHooks, ...MyHooks}
})
liveSocket.connect()Phoenix resolves calendar_component from deps/ through the NODE_PATH set in the default
esbuild configuration.
CSS
Import the EventCalendar stylesheet in assets/css/app.css:
@import "../../deps/calendar_component/priv/static/assets/main.css";Add the ec-dark class to any parent element (or ec-auto-dark to follow the system
preference) to enable the built-in dark theme, or override the CSS variables:
.ec {
--ec-bg-color: #22272e;
--ec-text-color: #adbac7;
}Usage with LiveView
defmodule MyAppWeb.CalendarLive do
use MyAppWeb, :live_view
import LiveCalendar.Components
def mount(_params, _session, socket) do
events = [
%{id: 1, title: "Planning", start: ~N[2026-10-05 09:00:00], end: ~N[2026-10-05 10:00:00]},
%{id: 2, title: "Offsite", start: ~D[2026-10-14], allDay: true}
]
{:ok, assign(socket, events: events)}
end
def render(assigns) do
~H"""
<.calendar
id="calendar"
events={@events}
options={%{
view: "dayGridMonth",
headerToolbar: %{start: "prev,next today", center: "title", end: "dayGridMonth,timeGridWeek,listWeek"},
firstDay: 1,
nowIndicator: true
}}
/>
"""
end
def handle_event("event_clicked", %{"id" => id}, socket), do: {:noreply, socket}
def handle_event("date_clicked", %{"dateStr" => date}, socket), do: {:noreply, socket}
def handle_event("month_changed", %{"month" => month, "year" => year}, socket), do: {:noreply, socket}
endThe container is rendered with phx-update="ignore": LiveView never touches the calendar DOM,
while changes to events and options are still applied by the hook. Only options whose value
changed are re-applied, so unrelated re-renders don't reset the date or the view the user
navigated to.
Callbacks
| Attribute | EventCalendar callback | Default server event |
|---|---|---|
on_event_click | eventClick | "event_clicked" |
on_date_click | dateClick | "date_clicked" |
on_month_change | datesSet | "month_changed" |
on_event_drop | eventDrop | none |
on_event_resize | eventResize | none |
on_select | select | none |
Each attribute accepts:
- a string: the event pushed to the server with the callback payload
- a
Phoenix.LiveView.JSstruct: commands run on the client. EveryJS.push/2in it receives the payload merged into its:value, soJS.push("select_event")gets the clicked event. The default event is not pushed when the commands already contain aJS.push/2. false: disables the default server event
<.calendar
id="calendar"
events={@events}
on_event_click={JS.push("select_event") |> JS.show(to: "#event-modal")}
on_date_click={false}
on_month_change="range_changed"
/>Payloads
Dates are sent in two formats:
start,end,date: instants with offset, e.g."2026-10-05T12:00:00.000Z"(useDateTime.from_iso8601/1)startStr,endStr,dateStr: the wall-clock time shown in the calendar, e.g."2026-10-05T09:00:00"(useNaiveDateTime.from_iso8601/1)
| Callback | Payload keys |
|---|---|
on_event_click | id, event, view |
on_date_click | date, dateStr, allDay, resource, view |
on_month_change | month, year, start, end, startStr, endStr, view |
on_event_drop | id, event, oldEvent, delta, oldResource, newResource, view |
on_event_resize | id, event, oldEvent, startDelta, endDelta, view |
on_select | start, end, startStr, endStr, allDay, resource, view |
event:id,title,start,end,startStr,endStr,allDay,resourceIds,display,backgroundColor,textColor,extendedPropsview:type,title,currentStart,currentEnd,activeStart,activeEndresource:id,title,extendedPropsdelta:years,months,days,seconds
month and year come from the current period (view.currentStart), so in month views they
match the displayed month even though the grid starts in the previous one.
Drag & drop, resize and selection
def render(assigns) do
~H"""
<.calendar
id="calendar"
events={@events}
options={%{view: "timeGridWeek", editable: true, selectable: true}}
on_event_drop="event_changed"
on_event_resize="event_changed"
on_select="range_selected"
/>
"""
end
def handle_event("event_changed", %{"id" => id, "event" => event}, socket) do
case Events.reschedule(id, event["start"], event["end"]) do
{:ok, _event} -> {:noreply, assign(socket, events: Events.list())}
# Moves the event back to where it was
{:error, _reason} -> {:reply, %{revert: true}, socket}
end
end
def handle_event("range_selected", %{"start" => start, "end" => finish}, socket) do
{:noreply, assign(socket, new_event: %{start: start, end: finish})}
endDriving the calendar from the server
socket
|> CalendarComponent.push_command("calendar", :goto_date, [~D[2026-12-01]])
|> CalendarComponent.push_command("calendar", :set_option, ["view", "timeGridDay"])Commands: :goto_date, :next, :prev, :today, :refetch_events, :refetch_resources,
:unselect and :set_option. Changing an option in options works as well, it is applied
whenever its value changes.
Custom buttons
Custom toolbar buttons can push an event when clicked:
options = %{
headerToolbar: %{start: "prev,next today", center: "title", end: "addEvent"},
customButtons: %{addEvent: %{text: "New event", push: "new_event_clicked"}}
}The handler receives %{"button" => "addEvent", "view" => view}.
JavaScript callbacks
Callback options can also reference global JavaScript functions, which run before the event is pushed to the server:
options={%{eventDidMount: "MyApp.Calendar.addTooltip", eventClick: "MyApp.Calendar.track"}}Usage with controllers
Use static_calendar/1 in regular controller templates:
<.static_calendar
id="calendar"
events={@events}
options={%{
view: "dayGridMonth",
eventClick: "MyApp.Calendar.onEventClick",
dateClick: "MyApp.Calendar.onDateClick"
}}
/>// assets/js/app.js
import {initStaticCalendars} from "calendar_component" // or "calendar_component/static"
window.MyApp = {
Calendar: {
onEventClick(info) { window.location = `/events/${info.event.id}` },
onDateClick(info) { window.location = `/events/new?date=${info.dateStr}` }
}
}
initStaticCalendars() // optional, calendars are also initialized on page loadCallback options given as strings are resolved to global functions. Short function bodies
receiving info are also accepted (max. 200 characters, keywords such as function, eval
or setTimeout are rejected), but global function references are recommended. See the
static calendar guide and security notes.
initStaticCalendar(el), getStaticCalendar(el), updateStaticCalendarEvents(el, events) and
destroyStaticCalendar(el) manage single calendars.
Events
Events are maps that are encoded with Jason (structs must be converted to maps first):
%{
id: 1,
title: "Meeting",
start: ~N[2026-10-05 09:00:00], # Date, NaiveDateTime, DateTime or ISO8601 string
end: ~N[2026-10-05 10:00:00], # optional
allDay: false, # optional
color: "#3b82f6", # optional, also backgroundColor / textColor
resourceId: 1, # or resourceIds: [1, 2]
display: "auto", # "background" for background events
extendedProps: %{room: "A"} # any extra data, sent back in payloads
}Datevalues ("2026-10-05") are all-day.NaiveDateTimevalues are shown as is, in the calendar's time zone.DateTimevalues are encoded with an offset ("...Z") and shifted to the calendar's time zone, which is the browser's by default. SettimeZone: "UTC"ortimeZone: "+02:00"inoptionsto use another one.- Events without
endlast one day (all-day) or one hour.
Options
Pass any EventCalendar option in options. Some common ones:
%{
view: "timeGridWeek",
date: "2026-10-01",
locale: "es",
firstDay: 1,
height: "700px",
slotMinTime: "07:00",
slotMaxTime: "20:00",
hiddenDays: [0, 6],
dayMaxEvents: true,
nowIndicator: true,
timeZone: "UTC",
headerToolbar: %{start: "prev,next today", center: "title", end: "dayGridMonth,timeGridWeek"},
views: %{timeGridFourDay: %{type: "timeGridDay", duration: %{days: 4}}}
}Available views: dayGridDay, dayGridWeek, dayGridMonth, timeGridDay, timeGridWeek,
listDay, listWeek, listMonth, listYear, resourceTimeGridDay, resourceTimeGridWeek,
resourceTimelineDay, resourceTimelineWeek, resourceTimelineMonth, resourceTimelineYear,
plus custom views defined in views with a type. All plugins are loaded, so any view can be
used without extra configuration.
For compatibility, initialView is accepted as an alias of view and headerToolbar accepts
left/right as aliases of start/end.
Resources
options = %{
view: "resourceTimelineWeek",
resources: [
%{id: 1, title: "Room A"},
%{id: 2, title: "Room B", children: [%{id: 3, title: "Booth B1"}]}
]
}
events = [%{id: 1, title: "Workshop", start: "2026-10-05T09:00:00", end: "2026-10-05T12:00:00", resourceIds: [1, 3]}]resources and eventSources can also be URLs (%{url: "/api/resources"}), refreshed with
push_command(socket, id, :refetch_resources) / :refetch_events.
Upgrading from 0.2.x
- EventCalendar was upgraded from 4.5 to 5.17. Its CSS Grid layout and styles changed, so review custom CSS (see the EventCalendar releases).
- Event dates ending in
Zor with an offset (for example encodedDateTimes) are now shifted to the calendar's time zone. UseNaiveDateTimeor settimeZoneto keep the old display. - The
viewoption is respected again (it was replaced by aninitialViewoption EventCalendar doesn't support). - JS commands given to the callback attributes now run (they were HTML-escaped and silently
failed). Their
JS.push/2operations receive the payload, and the default event is no longer pushed when they contain one. Make sure those events havehandle_event/3clauses. - Events are no longer filtered client side: events with
resourceIdsare shown, and an emptyeventslist clears the calendar. month_changedreports the displayed month in month views (it used to report the month of the first visible day).- The unused
tzdataandmimerldependencies and the placeholderhellofunction were removed.
Development
mix setup # deps, esbuild, assets and format
cd assets && npm install # EventCalendar sources
mix assets.build # builds priv/static/assets
mix test # Elixir tests
cd assets && npm test # JavaScript tests
Acknowledgments
This library builds on EventCalendar by Vlad Kurko. Thanks to the EventCalendar project for providing a lightweight, flexible calendar core.
License
MIT. See the LICENSE file for details.