PhoenixLiveCalendar

A comprehensive calendar and scheduling component library for Phoenix LiveView.

Server-rendered calendar views with optional drag interactions, real-time PubSub sync, booking constraints, and Ecto persistence. Zero JavaScript required for the base layer.

Phoenix-first — it looks right without JavaScript

This is the guiding principle: every view (month, week, day, N-day, year, agenda, timeline, resource) is computed in Elixir and rendered as plain HEEx + Tailwind over the LiveView socket — no charting JS, no <canvas>, and nothing that has to boot on the client for the layout to be correct. The JS hooks are progressive enhancement only (drag-to-select / move / resize, the day-marker ticker, touch handling). With them absent the calendar still renders and works: navigation, view switching, date/event clicks, and the detail popover are all server-driven phx-clicks, and a day with multiple markers still shows its first marker (you only lose the cycling). Add the hooks for richer interaction; never depend on them for the page to look right.

Features

View maturity: All eight views render server-side and work today. Month is the most polished and the view tuned for small screens; the others are functional but less refined — in particular the time-grid views (week / day / N-day) are not yet optimised for phone widths.

Installation

Add phoenix_live_calendar to your dependencies:

def deps do
[
{:phoenix_live_calendar, "~> 0.1.0"}
]
end

Add to your assets/css/app.css so Tailwind scans the component templates:

@source "../../deps/phoenix_live_calendar";

Optional: JS hooks

For drag interactions, add to assets/js/app.js:

import "../../deps/phoenix_live_calendar/priv/static/assets/phoenix_live_calendar.js"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...window.PhoenixLiveCalendarHooks, ...Hooks }
})

Optional: Ecto persistence

# config/config.exs
config :phoenix_live_calendar, repo: MyApp.Repo
# Generate and run the migration
mix ecto.gen.migration add_phoenix_live_calendar

Edit the migration:

defmodule MyApp.Repo.Migrations.AddPhoenixLiveCalendar do
use Ecto.Migration
def up, do: PhoenixLiveCalendar.Store.Ecto.Migrations.up(version: 1)
def down, do: PhoenixLiveCalendar.Store.Ecto.Migrations.down(version: 1)
end

Quick Start

defmodule MyAppWeb.CalendarLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
events = [
PhoenixLiveCalendar.event("1", ~U[2026-04-01 09:00:00Z],
title: "Team Standup",
end: ~U[2026-04-01 09:30:00Z],
color: "bg-primary"
),
PhoenixLiveCalendar.event("2", ~D[2026-04-05],
title: "Company Holiday",
all_day: true,
color: "bg-success"
)
]
{:ok, assign(socket, events: events)}
end
def render(assigns) do
~H"""
<.live_component
module={PhoenixLiveCalendar.CalendarComponent}
id="my-calendar"
events={@events}
views={[:month, :week, :day, :agenda]}
on_date_select={fn date -> send(self(), {:date_selected, date}) end}
on_event_click={fn id -> send(self(), {:event_clicked, id}) end}
/>
"""
end
def handle_info({:date_selected, date}, socket) do
IO.inspect(date, label: "Selected date")
{:noreply, socket}
end
def handle_info({:event_clicked, event_id}, socket) do
IO.inspect(event_id, label: "Clicked event")
{:noreply, socket}
end
end

Configuration Options

OptionTypeDefaultDescription
viewatom:monthInitial view (:month, :week, :day, :year, :agenda, :timeline, :resource)
viewslist[:month, :week, :day]Available views in the switcher
dateDatetodayInitial date
week_startinteger1First day of week (1=Mon, 7=Sun)
min_timeTime~T[00:00:00]Earliest visible time in grid
max_timeTime~T[23:59:59]Latest visible time in grid
slot_durationinteger30Time slot duration in minutes
time_formatatom:h24:h24 or :h12
show_week_numbersbooleanfalseShow ISO week numbers
show_weekendsbooleantrueShow Saturday/Sunday
max_eventsinteger3Max events per month cell
n_daysinteger4Number of days for N-day view
diratom:ltrText direction (:ltr or :rtl)
translationsmap%{}Label overrides
business_hourslist[]Availability windows to highlight

Callbacks

CallbackPayloadDescription
on_date_selectDate.t()Date clicked
on_time_select%{date, time, datetime, resource_id}Time slot clicked
on_event_clickevent_idEvent clicked
on_view_change%{view, date}View switched
on_date_range_change%{start, end, view, date}Visible range changed

Using Individual Views

You can use any view component standalone without the LiveComponent wrapper:

<PhoenixLiveCalendar.Views.MonthGrid.month_grid
date={~D[2026-04-01]}
events={@events}
on_date_click={JS.push("date_clicked")}
/>
<PhoenixLiveCalendar.Views.Agenda.agenda
date={Date.utc_today()}
events={@events}
days={14}
/>

License

MIT License - see LICENSE for details.