PhoenixLiveCalendar

Copy Markdown View Source

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

  • 8 view types: Month, Week, Day, N-day (flexible), Year, Agenda, Timeline, Resource columns
  • Pure Elixir base layer: Works without any JavaScript
  • Heatmaps: Heatmap.markers/2 maps per-day numbers onto intensity-tinted month/year cells (GitHub-contributions style) — :fill or quieter :dot style, palette presets (:success/:heat/:cool/:mono)
  • Widgets: Widgets.next_events/week_strip/activity_grid/mini_timeline + MiniCalendar — the most compressed useful form of each surface for ~200×150px dashboard cells
  • Layers: named event sets with a legend of toggle chips, filtered server-side — withheld layers never reach the client
  • Semantic color tokens: Event.color takes :primary-style atoms or app-configured tokens as well as raw classes
  • Customization slots forwarded through the CalendarComponent (:event, :day_cell, :time_label, …)
  • Range-driven windowing: events_mode: :window renders only the visible slice — pair with on_date_range_change
  • Progressive enhancement: Optional JS hooks for drag-to-select, drag-to-move, resize
  • Real-time sync: Optional PubSub integration for multi-user calendars
  • Booking system: Availability windows, slot constraints, capacity, buffers, validation
  • Accessibility-minded: ARIA grid roles, roving tabindex, and screen-reader labels (full arrow-key grid navigation + focus restoration are on the roadmap)
  • RTL support: Full right-to-left layout for Arabic, Hebrew, Persian, Urdu
  • i18n: All labels translatable via Gettext or override map
  • Tailwind CSS: Uses daisyUI semantic classes, works with any Tailwind theme
  • Optional Ecto: Opt-in persistence with Oban-style versioned migrations
  • Dashboard-ready: All components work at any container size

View maturity: All eight views render server-side and work today. Month, week and day are the polished, phone-tuned views (the time grids gained day markers, dense all-day lanes, detail event blocks and responsive columns in 0.3). Timeline and resource were substantially hardened in 0.3 (midnight clamping, fitted windows, sticky columns, now line, bar labels, shared day-segment math); year and agenda are functional but less refined.

Installation

Add phoenix_live_calendar to your dependencies:

def deps do
  [
    {:phoenix_live_calendar, "~> 0.3.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
todayDate/:noneUTC todayTimezone-correct today (seeds the starting month when date is unset); :none disables ALL today highlighting — archive views
nowTimeUTC nowWall-clock time for the now indicator; pass the viewer's local time with a timezone-correct today
events_modeatom:full:window trims events to those occupying the visible range — pair with on_date_range_change for range-driven fetching
layerslist[]Layer structs — legend toggle chips; hidden layers' events are filtered server-side; events inherit their layer's color
show_legendbooleantrueHide the layer legend row
event_contentatom:autoWeek/day block content by estimated height: :detail / :inline / :title / :none tiers — whole lines or none, never clipped text; pass a tier to force it
min_event_heightstring"1.25rem"Height floor for week/day event blocks (one text line); "0" disables
header_layoutatom:autoToolbar collapses to a start-aligned row when both wings are empty; :centered/:start force
label_positionatom:fitTimeline bar labels: inside when the estimate fits, else outside beside the bar; :inside/:outside/:none force
label_fit_ratiofloat0.75How much of the estimated label must fit for :fit to choose inside
label_fit_fallbackatom:outsideWhat :fit does when the label doesn't fit: :outside or :none (tooltip only)
show_time_axisbooleantrueTimeline: render the sticky hour header
day_markerslist[]DayMarker structs — cell tints + labels in the month, week, day AND year views; a marker's own color/text_color/class/show_label: false enable heatmap-style views
filter_to_datebooleantrueTimeline: only render events occupying the displayed date
clamp_to_datebooleantrueTimeline: clamp midnight-crossing events to the displayed date (23:50→00:20 renders on both days correctly)
sticky_resource_columnbooleantrueTimeline: pin the resource label column during horizontal scroll
fit_to_eventsbooleanfalseTimeline: size the visible window to the rendered events (hour-rounded) instead of min_time/max_time
show_now_indicatorbooleantrueCurrent-time line in day/week/timeline views when showing today

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
on_layers_change%{visible: ids, hidden: ids}A legend chip was toggled

Heatmaps

# Date => number, bucketed onto an intensity palette
markers = PhoenixLiveCalendar.Heatmap.markers(minutes_by_day, scale: :quantile)

<.live_component module={PhoenixLiveCalendar.CalendarComponent} id="history" day_markers={markers} />

# quieter dot style, preset palettes (:success | :heat | :cool | :mono)
PhoenixLiveCalendar.Heatmap.markers(data, style: :dot, palette: :heat)

Widgets

The most compressed useful form of each surface, for dashboard cells:

<PhoenixLiveCalendar.Widgets.next_events events={@events} limit={3} />
<PhoenixLiveCalendar.Widgets.week_strip events={@events} />
<PhoenixLiveCalendar.Widgets.activity_grid data={@minutes_by_day} />
<PhoenixLiveCalendar.Widgets.activity_month data={@minutes_by_day} />
<PhoenixLiveCalendar.Widgets.mini_timeline date={@today} resources={@resources} events={@events} />
<PhoenixLiveCalendar.Components.MiniCalendar.mini_calendar date={@today} events_by_date={@by_date} />

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.