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))}
enddate_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)
)}
enddate_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
endweek_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)}
endcalendar_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)}
endschedule_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))
endtime_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)
endbadge_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)
endstreak_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)