Calendar & Scheduling

Copy Markdown View Source

PhiaUI's standout feature: 33 server-rendered calendar and scheduling components covering every date, time, and scheduling pattern in enterprise and consumer software. All calendar geometry is computed server-side using Elixir's Date module. State lives in your LiveView; events fire via phx-click. No client-side date math.

Table of Contents


Basic Pickers

calendar

Server-rendered monthly grid. Single or range selection mode. MON-first by default.

Hook: PhiaCalendar Attrs: id, value (Date.t), on_change, mode (:single/:range), min, max, disabled_dates

<%!-- Single date selection --%>
<.calendar
  id="booking-cal"
  value={@selected_date}
  on_change="pick-date"
/>

<%!-- With constraints --%>
<.calendar
  id="delivery-cal"
  value={@delivery_date}
  min={Date.utc_today()}
  max={Date.add(Date.utc_today(), 30)}
  disabled_dates={@holiday_dates}
  on_change="pick-delivery"
/>
def handle_event("pick-date", %{"date" => iso}, socket) do
  {:noreply, assign(socket, selected_date: Date.from_iso8601!(iso))}
end

date_picker

Calendar grid opened in a Popover. Formats the selected date in the trigger button.

Attrs: id, value, on_change, format (e.g. "%B %d, %Y"), placeholder, min, max

<.date_picker
  id="due-date"
  value={@due_date}
  on_change="set-due-date"
  placeholder="Pick a date"
  min={Date.utc_today()}
/>

<%!-- In a form --%>
<.field>
  <.field_label>Publish date</.field_label>
  <.date_picker id="publish-date" value={@publish_date} on_change="set-publish-date" />
</.field>

date_range_picker

Dual-calendar popover for selecting a start and end date. Highlights the selected range.

Hook: PhiaDateRangePicker Attrs: id, from (Date.t), to (Date.t), on_change, min, max, placeholder_from, placeholder_to

<.date_range_picker
  id="report-range"
  from={@date_from}
  to={@date_to}
  on_change="set-date-range"
  placeholder_from="Start date"
  placeholder_to="End date"
/>
def handle_event("set-date-range", %{"from" => from_iso, "to" => to_iso}, socket) do
  {:noreply, assign(socket,
    date_from: Date.from_iso8601!(from_iso),
    date_to: Date.from_iso8601!(to_iso)
  )}
end

date_field

Segmented day/month/year inputs with independent keyboard stepping.

Attrs: id, value (Date.t), on_change FormField variant: form_date_field/1

<.date_field id="dob" value={@date_of_birth} on_change="set-dob" />
<.form_date_field field={@form[:date_of_birth]} label="Date of birth" />

time_picker

Hour/minute/AM-PM selector. 12h or 24h format.

Attrs: id, value (time string "HH:MM"), on_change, format ("12h"/"24h"), step (minutes, default 15) FormField variant: form_time_picker/1

<.time_picker id="appt-time" value={@appointment_time} on_change="set-time" format="12h" />
<.time_picker id="meeting-time" value={@meeting_time} on_change="set-time" format="24h" step={30} />
<.form_time_picker field={@form[:start_time]} label="Start time" format="24h" />

date_time_picker

Combined date + time selector in a single popover.

Attrs: id, value (DateTime.t), on_change, format, min, max FormField variant: form_date_time_picker/1

<.date_time_picker
  id="event-datetime"
  value={@event_start}
  on_change="set-event-start"
  min={DateTime.utc_now()}
/>

<.form_date_time_picker
  field={@form[:scheduled_at]}
  label="Schedule for"
  min={DateTime.utc_now()}
/>

month_picker

12-month pill grid with year navigation.

Attrs: id, value (1–12 or Date.t), year, on_change FormField variant: form_month_picker/1

<.month_picker id="report-month" value={@selected_month} year={@year} on_change="set-month" />
<.form_month_picker field={@form[:billing_month]} label="Billing month" />

year_picker

Scrollable year grid with min/max bounds.

Attrs: id, value (integer), min_year, max_year, on_change FormField variant: form_year_picker/1

<.year_picker id="birth-year" value={@birth_year} min_year={1900} max_year={Date.utc_today().year} on_change="set-year" />

week_picker

ISO week selector (W01/2026 format) with week highlighting in the grid.

Attrs: id, value (%{week: integer, year: integer}), on_change FormField variant: form_week_picker/1

<.week_picker id="sprint-week" value={@sprint_week} on_change="set-sprint-week" />

week_day_picker

Mon–Sun pill toggles for recurrence rules and availability schedules. Multi-select.

Attrs: id, value (list of strings, e.g. ["mon", "wed", "fri"]), on_change

<%!-- Recurring schedule days --%>
<.week_day_picker id="recurring-days" value={@recurring_days} on_change="set-days" />

<%!-- Availability days --%>
<.field>
  <.field_label>Available on</.field_label>
  <.week_day_picker id="availability" value={@available_days} on_change="set-availability" />
</.field>

Advanced Calendar Views

range_calendar

SUN-first month grid with visual range band: start/end circles with half-band fill on edges, full-band on interior days. Blue circular navigation buttons.

Attrs: id, from, to, on_change, min, max

<.range_calendar
  id="vacation-range"
  from={@vacation_from}
  to={@vacation_to}
  on_change="set-vacation"
  min={Date.utc_today()}
/>

calendar_time_picker

Full monthly calendar with an inline time selector rendered below the grid in a single component.

Attrs: id, value (DateTime.t), on_change, format

<.calendar_time_picker
  id="appt-picker"
  value={@appointment_datetime}
  on_change="set-appointment"
/>

date_range_presets

Date range picker extended with preset shortcut buttons.

Default presets: Today, Yesterday, Last 7 days, Last 30 days, This month, Last month Attrs: id, from, to, on_change, presets (optional custom list)

<%!-- Analytics date range with presets --%>
<.date_range_presets
  id="analytics-range"
  from={@date_from}
  to={@date_to}
  on_change="set-analytics-range"
/>

<%!-- Custom presets --%>
<.date_range_presets
  id="report-range"
  from={@from}
  to={@to}
  on_change="set-range"
  presets={[
    %{label: "This week",    from: Date.beginning_of_week(Date.utc_today()), to: Date.utc_today()},
    %{label: "Last quarter", from: Date.add(Date.utc_today(), -90), to: Date.utc_today()}
  ]}
/>

date_strip

Horizontal scrollable row of date_card/1 components. Auto-scrolls to the selected day using inline JS.

Attrs: id, dates (list of Date.t), selected (Date.t), on_select

<%!-- Week strip for a scheduling UI --%>
<.date_strip
  id="week-strip"
  dates={Enum.map(0..6, &Date.add(Date.utc_today(), &1))}
  selected={@selected_date}
  on_select="select-date"
/>

date_card

Single day card with 4 visual states.

States: default, today, selected, disabled Attrs: date, state, on_select

<div class="grid grid-cols-7 gap-1">
  <.date_card
    :for={day <- @week_days}
    date={day}
    state={day_state(day, @selected_date)}
    on_select="select-day"
  />
</div>
defp day_state(date, selected) do
  cond do
    date == selected       -> "selected"
    date == Date.utc_today() -> "today"
    Date.before?(date, Date.utc_today()) -> "disabled"
    true -> "default"
  end
end

week_calendar

Compact week navigator: month title, prev/next navigation arrows, 7-day strip with selected day pill.

Attrs: id, value (Date.t — selected day), on_change

<.week_calendar id="week-nav" value={@selected_date} on_change="change-week" />

big_calendar

Full-page calendar with month/week/day view switcher. MON-first. Event pills (max 3 visible + "+N more" overflow). Sub-component: big_calendar_event/1.

Attrs: id, events (list of %{id, title, date, color}), view (:month/:week/:day), date, on_view_change, on_date_click, on_event_click

<.big_calendar
  id="main-calendar"
  events={@calendar_events}
  view={@calendar_view}
  date={@calendar_date}
  on_view_change="change-view"
  on_date_click="click-date"
  on_event_click="click-event"
/>
def handle_event("change-view", %{"view" => view}, socket) do
  {:noreply, assign(socket, calendar_view: String.to_existing_atom(view))}
end

def handle_event("click-date", %{"date" => iso}, socket) do
  date = Date.from_iso8601!(iso)
  events = Events.for_date(date)
  {:noreply, assign(socket, calendar_date: date, day_events: events)}
end

calendar_week_view

Week grid with a time axis (Y-axis, 00:00–23:00). Events are absolutely positioned based on start_time and duration converted to pixel offsets.

Attrs: id, events (list of %{id, title, start_time, duration_minutes, color}), date (any day in the week)

<.calendar_week_view
  id="week-view"
  date={@week_start}
  events={@week_events}
/>

wheel_picker

iOS-style scroll-snap wheel picker. Uses scroll-snap-type: y mandatory + inline JS for sync.

Attrs: id, items (list), value, on_change, columns (for multi-column pickers)

<%!-- Simple list --%>
<.wheel_picker id="hour-picker" items={Enum.map(0..23, &String.pad_leading("#{&1}", 2, "0"))}
  value={@hour} on_change="set-hour" />

<%!-- Month/Year picker --%>
<.wheel_picker
  id="month-year"
  columns={[
    %{id: "month", items: ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec), value: @month},
    %{id: "year",  items: Enum.map(2020..2030, &to_string/1), value: @year}
  ]}
  on_change="set-month-year"
/>

Scheduling & Booking

event_calendar

Month grid with clickable event pills per day. Day click reveals event list.

Attrs: id, events (list of %{date, title, color}), on_event_click, on_date_click

<.event_calendar
  id="events-cal"
  events={@events}
  on_event_click="open-event"
  on_date_click="show-day"
/>

booking_calendar

Availability-aware booking calendar. Each date maps to an availability status.

Attrs: id, availability (%{Date.t => :available | :unavailable | :check_in_only | :check_out_only}), selected, on_select, min_date, max_date

<.booking_calendar
  id="book-date"
  availability={@availability}
  selected={@selected_date}
  on_select="select-date"
  min_date={Date.utc_today()}
/>
def mount(_params, _session, socket) do
  # Build availability map for next 90 days
  availability = Bookings.availability_map(
    Date.utc_today(),
    Date.add(Date.utc_today(), 90)
  )
  {:ok, assign(socket, availability: availability, selected_date: nil)}
end

def handle_event("select-date", %{"date" => iso}, socket) do
  date = Date.from_iso8601!(iso)
  slots = Bookings.available_slots(date)
  {:noreply, assign(socket, selected_date: date, available_slots: slots)}
end

schedule_view

Google Calendar-style agenda view. Supports day and week modes.

Attrs: id, events (list of %{id, title, start_datetime, end_datetime, color, location}), view (:day/:week), date, on_event_click

<div class="flex items-center gap-2 mb-4">
  <.segmented_control
    id="schedule-view-mode"
    name="view"
    value={@schedule_view}
    on_change="change-schedule-view"
    segments={[%{value: "day", label: "Day"}, %{value: "week", label: "Week"}]}
  />
  <.button variant="outline" size="icon" phx-click="prev-period">
    <.icon name="chevron-left" size="sm" />
  </.button>
  <.button variant="outline" size="icon" phx-click="next-period">
    <.icon name="chevron-right" size="sm" />
  </.button>
</div>

<.schedule_view
  id="my-schedule"
  events={@schedule_events}
  view={String.to_existing_atom(@schedule_view)}
  date={@schedule_date}
  on_event_click="open-event"
/>

daily_agenda

Single-day chronological list view grouped by hour.

Attrs: id, date, events (list of %{time, title, duration_minutes, color, location})

<.daily_agenda
  id="today-agenda"
  date={Date.utc_today()}
  events={@todays_events}
/>

schedule_event_card

Rich card for schedule and agenda displays. Use inside daily_agenda or any list.

Attrs: title, time, duration, location, attendees (list), status, color

<.schedule_event_card
  :for={event <- @events}
  title={event.title}
  time={event.start_time}
  duration="60 min"
  location={event.location}
  status={event.status}
  color={event.color}
/>

time_slot_grid

Grid of bookable time slots. Available, booked, and selected states.

Attrs: id, slots (list of strings or maps), selected, on_select, cols (columns, default 4)

<.time_slot_grid
  id="time-slots"
  slots={@available_slots}
  selected={@selected_slot}
  on_select="select-slot"
  cols={4}
/>
# Build slots from business hours
def available_slots(date) do
  booked = Bookings.booked_slots(date)
  Enum.reject(~w[09:00 09:30 10:00 10:30 11:00 11:30
                  14:00 14:30 15:00 15:30 16:00 16:30], &(&1 in booked))
end

time_slot_list

Vertical list variant of time slot selection. Better for mobile layouts.

Attrs: id, slots (list of %{time, label, available}), selected, on_select

<.time_slot_list
  id="slot-list"
  slots={@slots}
  selected={@selected_slot}
  on_select="select-slot"
/>

time_slider_picker

Dual-handle range slider for selecting a time window.

Attrs: id, from (minutes since midnight), to, min, max, step, on_change

<%!-- 9am to 5pm business hours --%>
<.time_slider_picker
  id="hours-range"
  from={@open_hour * 60}
  to={@close_hour * 60}
  min={0}
  max={1440}
  step={30}
  on_change="set-business-hours"
/>

multi_select_calendar

Monthly grid where each day toggles on/off. Returns a list of selected dates.

Attrs: id, value (list of Date.t), on_change, min, max

<%!-- Blackout dates for a venue --%>
<.multi_select_calendar
  id="blackout-dates"
  value={@blackout_dates}
  on_change="set-blackout-dates"
/>

Specialized Displays

heatmap_calendar

GitHub-style contribution grid with intensity levels. Full WAI-ARIA role="grid".

Attrs: id, data (%{Date.t => integer}), max_value, rows (7), cols (52), col_labels, row_labels, show_legend

<.heatmap_calendar
  data={@contribution_data}
  rows={7}
  cols={52}
  max_value={10}
  col_labels={@week_labels}
  row_labels={~w(Mon Tue Wed Thu Fri Sat Sun)}
  show_legend={true}
/>
# Build contribution data from DB
def contribution_data(user_id) do
  user_id
  |> ActivityLog.for_user()
  |> Enum.group_by(& &1.date)
  |> Map.new(fn {date, entries} -> {date, length(entries)} end)
end

badge_calendar

Monthly calendar where each day displays a count badge (notifications, events, tasks).

Attrs: id, badges (%{Date.t => integer}), value, on_change

<.badge_calendar
  id="task-calendar"
  badges={@task_counts_by_date}
  value={@selected_date}
  on_change="select-date"
/>
def task_counts_by_date(user_id) do
  Tasks.list_by_user(user_id)
  |> Enum.group_by(& Date.from_naive!(&1.due_date, "Etc/UTC"))
  |> Map.new(fn {date, tasks} -> {date, length(tasks)} end)
end

streak_calendar

Habit tracker calendar that highlights consecutive-day streaks with intensity levels.

Attrs: id, data (%{Date.t => boolean}), streak_color, show_stats

<.streak_calendar
  id="habit-tracker"
  data={@habit_completions}
  streak_color="green"
  show_stats={true}
/>

multi_month_calendar

Side-by-side multiple month view for date range selection across month boundaries.

Attrs: id, months (2–4, default 2), value, on_change, mode (:single/:range)

<%!-- Side-by-side for hotel booking --%>
<.multi_month_calendar
  id="hotel-booking"
  months={2}
  value={@selected_date}
  on_change="set-checkout"
  mode={:range}
/>

countdown_timer

Server-rendered countdown timer updated via LiveView Process.send_after loop. Displays DD:HH:MM:SS.

Attrs: id, target (DateTime.t), on_end (event name), format

<%!-- Auction countdown --%>
<.countdown_timer
  id="auction-timer"
  target={@auction_ends_at}
  on_end="auction-ended"
/>

<%!-- Sale ending banner --%>
<div class="bg-primary text-primary-foreground py-2 px-4 text-center">
  <span class="font-medium">Flash sale ends in: </span>
  <.countdown_timer id="sale-timer" target={@sale_ends_at} on_end="sale-ended" />
</div>
def mount(_params, _session, socket) do
  if connected?(socket), do: schedule_tick()
  {:ok, assign(socket, auction_ends_at: ~U[2026-03-06 18:00:00Z])}
end

def handle_info(:tick, socket) do
  schedule_tick()
  {:noreply, socket}
end

def handle_event("auction-ended", _params, socket) do
  {:noreply, assign(socket, auction_active: false)}
end

defp schedule_tick, do: Process.send_after(self(), :tick, 1_000)

Back to README