Calendar
A roving-tabindex month grid that posts ISO 8601. Single, range and multiple modes, min and max windows, and month paging that works evented or as patch links.
A month, selected
A server-rendered month grid on plain Elixir Date. Wire on_select and on_month_change and your handle_event owns the value; leave them off and the nav becomes query-param links that work in a dead view.
<.calendar id="showcase-calendar" value={~D[2026-03-14]} month={~D[2026-03-01]} />
Range selection
Range mode takes a {from, to} pair. Either side may be nil while a selection is in flight, so the half-picked state renders without a special case.
<.calendar
id="showcase-calendar-range"
mode="range"
value={{~D[2026-03-09], ~D[2026-03-17]}}
month={~D[2026-03-01]}
/>
Multiple days
Pass a list and every date in it is selected. Handy for availability and recurring-day pickers.
<.calendar
id="showcase-calendar-multiple"
mode="multiple"
value={[~D[2026-03-03], ~D[2026-03-11], ~D[2026-03-25]]}
month={~D[2026-03-01]}
/>
Windows and blackout days
min and max bound the window; disabled_dates takes a list or a function. Disabled days still render and stay focusable, per the APG - they just can't be picked.
<.calendar
id="showcase-calendar-limits"
month={~D[2026-03-01]}
min={~D[2026-03-04]}
max={~D[2026-03-27]}
disabled_dates={&(Date.day_of_week(&1) in [6, 7])}
/>
Availability, with the taken days crossed out
An artist's booking calendar. disabled_dates carries the days already spoken for - a list of Dates here, or a 1-arity function when the rule is computed - and the component blocks the click. Drawing the line through them is the slot's job: the day it hands you carries :disabled, which is the flag the treatment hangs off. The strike also nudges the colour a stop back toward legible, because a line drawn in the component's own disabled gray is a line you squint at.
<.calendar
id="showcase-calendar-availability"
month={~D[2026-03-01]}
value={~D[2026-03-17]}
disabled_dates={[
~D[2026-03-04],
~D[2026-03-05],
~D[2026-03-12],
~D[2026-03-13],
~D[2026-03-19],
~D[2026-03-25],
~D[2026-03-26]
]}
>
<:day :let={day}>
<span class={[day.disabled && "line-through text-gray-400 dark:text-gray-500"]}>
{day.date.day}
</span>
</:day>
</.calendar>
Booking calendar, prices in the cells
Two dials, one grid. --pc-calendar-cell-size makes room for a second line and the :day slot fills it. The slot owns the button's content, so the number is yours to render, and the day it hands you carries the flags the content needs: selected softens the price against the inverse chip instead of fighting it, outside keeps the neighbouring month quiet, and the lowest fares pick up the success colour.
<.calendar
id="showcase-calendar-booking"
mode="range"
value={{~D[2026-03-09], ~D[2026-03-13]}}
month={~D[2026-03-01]}
class="[--pc-calendar-cell-size:2.75rem] sm:[--pc-calendar-cell-size:3.5rem]"
>
<:day :let={day}>
<span class="flex flex-col items-center gap-0.5 leading-none">
<span>{day.date.day}</span>
<span
:if={!day.outside}
class={
[
"text-xs",
cond do
day.selected -> "opacity-70"
Date.day_of_week(day.date) in [5, 6] -> "text-gray-500 dark:text-gray-400"
# success-700 in light, not the house's usual 600: at text-xs the
# 600 stop probes 3.22:1 on white, under the AA 4.5:1 line.
true -> "text-success-700 dark:text-success-400"
end
]
}
>
{if Date.day_of_week(day.date) in [5, 6], do: "$320", else: "$240"}
</span>
</span>
</:day>
</.calendar>
Composed into a card
The calendar paints no background and no padding of its own, so it drops straight into another component's surface. Here it is the body of a card, between the card's own header and footer - no calendar variant required.
<.card class="max-w-max">
<.card_header title="Book a consultation" description="Weekdays only, 45 minutes." />
<.card_content>
<.calendar
id="showcase-calendar-card"
month={~D[2026-03-01]}
value={~D[2026-03-18]}
disabled_dates={&(Date.day_of_week(&1) in [6, 7])}
/>
</.card_content>
<.card_footer>
<.button size="sm" label="Confirm 18 March" />
</.card_footer>
</.card>
The appointment picker, composed
The whole booking widget, and none of it is a calendar feature. A card holds the header, the grid and its time rail side by side, then a footer that carries what you picked and the button that commits it. The rail is a div with a max height and overflow-y-auto, the slots are buttons, the footer's rule is a border-t you add. The calendar paints no surface of its own, which is what lets it sit inside someone else's.
<.card class="max-w-max">
<.card_header title="Book your appointment" description="30 minutes, on a video call." />
<.card_content class="flex flex-col gap-6 sm:flex-row">
<.calendar
id="showcase-calendar-appointments"
month={~D[2026-03-01]}
value={~D[2026-03-18]}
disabled_dates={&(Date.day_of_week(&1) in [6, 7])}
/>
<%!-- sm:h-0 sm:min-h-full: the rail contributes nothing to the row's
height (the calendar alone sets it) and then stretches to exactly that
height, scrolling the excess - so the two columns always end together,
whatever the month's row count. Stacked mobile keeps a plain cap. --%>
<div class="flex flex-col gap-2 pr-1 overflow-y-auto max-h-56 sm:h-0 sm:max-h-none sm:min-h-full sm:w-24">
<.button color="gray" variant="outline" size="sm" label="09:00" />
<.button color="gray" variant="outline" size="sm" label="09:30" />
<.button color="gray" variant="outline" size="sm" label="10:00" />
<%!-- The picked slot wears the calendar chip's exact inverse - the
card holds two selections, a date and a time, and they speak one
state language. Utilities beat the button's components-layer
styling, so these ride a plain class. --%>
<.button
color="gray"
size="sm"
label="10:30"
class="bg-gray-900 text-white dark:bg-white dark:text-gray-900"
/>
<.button color="gray" variant="outline" size="sm" label="11:00" />
<.button color="gray" variant="outline" size="sm" label="13:30" />
<.button color="gray" variant="outline" size="sm" label="14:00" />
<.button color="gray" variant="outline" size="sm" label="14:30" />
</div>
</.card_content>
<.card_footer class="justify-between pt-6 border-t border-gray-200 dark:border-gray-400/17">
<span class="text-sm text-gray-500 dark:text-gray-400">Wednesday 18 March, 10:30</span>
<.button size="sm" label="Confirm" />
</.card_footer>
</.card>
Sunday-first, fixed height
starts_on takes ISO day numbers, so 7 is Sunday. fixed_weeks always draws six rows, which stops the grid jumping height as you page through the year.
<.calendar
id="showcase-calendar-sunday"
month={~D[2026-02-01]}
starts_on={7}
fixed_weeks
show_outside_days={false}
/>
Properties
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
extra classes for the calendar wrapper |
day_names
|
list |
["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
7 short day labels ordered Monday-first; default English |
day_names_long
|
list |
["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
7 full day labels ordered Monday-first, used for screen readers; default English |
disabled_dates
|
any |
nil
|
a list of Dates, or a 1-arity function Date -> boolean; disabled days render but are not selectable |
fixed_weeks
|
boolean |
false
|
always render 6 week rows so the grid height never jumps |
id
|
string |
the calendar id; autogenerated if not set | |
max
|
any |
nil
|
latest selectable Date (inclusive) |
min
|
any |
nil
|
earliest selectable Date (inclusive) |
mode
|
string |
"single"
|
selection behaviour
one of: "single", "range", "multiple"
|
month
|
any |
nil
|
the displayed month as any Date within it; defaults to the selected value's month, else today |
month_names
|
list |
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
12 month labels; default English |
month_param
|
string |
"month"
|
query param used for link-based month navigation |
name
|
any |
nil
|
when set (and no on_select), day buttons submit this form field with the ISO date as value |
nav
|
string |
"both"
|
which month-nav arrows to render; two side-by-side panes want prev on the left pane and next on the right
one of: "both", "prev", "next", "none"
|
next_label
|
string |
"Next month"
|
accessible label for the next button |
on_month_change
|
any |
nil
|
event name for prev/next month; when nil, nav renders as patch links using the month_param query param |
on_select
|
any |
nil
|
event name pushed when a day is clicked; the payload carries the ISO date |
prev_label
|
string |
"Previous month"
|
accessible label for the prev button |
rest
|
global |
||
show_outside_days
|
boolean |
true
|
render leading/trailing days from adjacent months |
starts_on
|
integer |
1
|
first day of the week, ISO day numbers (1 = Monday, 7 = Sunday)
one of: 1, 2, 3, 4, 5, 6, 7
|
target
|
any |
nil
|
phx-target for the select and month-change events; set it to @myself when the calendar lives inside a LiveComponent |
today
|
any |
nil
|
the date treated as today; defaults to Date.utc_today/0. Pass the user's local date if UTC is not good enough |
value
|
any |
nil
|
the selection: a Date (single), a {from, to} tuple or a map with :from/:to (range, either side may be nil mid-selection), or a list of Dates (multiple) |
:day
slot
|
slot |
content for every day button, in place of the bare number - render the number yourself. Receives the day: :date (a Date), :iso, and the :today, :outside, :disabled, :selected, :range_start, :range_middle and :range_end flags. Pair it with a bigger --pc-calendar-cell-size when the content needs a second line |