A server-rendered month grid built on Elixir's Date. No time, no timezone,
no DateTime: values in and out are %Date{} and anything posted is ISO 8601.
The grid follows the WAI-ARIA grid pattern. Every day is a real <button>, so
Tab plus Enter works with JavaScript disabled; the PetalCalendar hook layers
roving tabindex and the arrow-key map on top.
Two wirings
With on_select and on_month_change set, days and nav buttons push events
carrying ISO strings:
<.calendar
id="due-date"
value={@due_on}
month={@month}
on_select="pick_date"
on_month_change="change_month"
/>
def handle_event("pick_date", %{"date" => iso}, socket) do
{:ok, date} = Date.from_iso8601(iso)
{:noreply, assign(socket, due_on: date)}
end
def handle_event("change_month", %{"month" => iso}, socket) do
{:ok, month} = Date.from_iso8601(iso)
{:noreply, assign(socket, month: month)}
endWith them left nil, month navigation renders as patch links carrying
month_param, which works in a dead view as a plain query-string link:
<.calendar month={@month} value={@value} month_param="month" name="booking[date]" />Give it a name and each day becomes a submit button, so a form wrapping the
calendar posts the clicked ISO date with no JavaScript at all.
Selection modes
<.calendar mode="single" value={~D[2026-03-14]} />
<.calendar mode="range" value={{~D[2026-03-10], ~D[2026-03-17]}} />
<.calendar mode="multiple" value={[~D[2026-03-03], ~D[2026-03-09]]} />In range mode either side may be nil while a selection is in flight, so a
half-open {~D[2026-03-10], nil} renders the anchor day and nothing else.
Limiting what can be picked
<.calendar
min={Date.utc_today()}
max={Date.add(Date.utc_today(), 30)}
disabled_dates={&(Date.day_of_week(&1) in [6, 7])}
/>disabled_dates takes a list of dates or a 1-arity function. Disabled days
still render and stay focusable (per the APG), they just cannot be selected.
Today, and timezones
today defaults to Date.utc_today/0. A %Date{} has no timezone, so the
component never converts one for you. If your users are not on UTC, pass the
date you consider today:
<.calendar today={DateTime.to_date(DateTime.shift_zone!(DateTime.utc_now(), @tz))} />Localisation
There is no date-localisation dependency here. Day and month names are plain attrs, so wire them to gettext yourself:
<.calendar
day_names={Enum.map(~w(Mon Tue Wed Thu Fri Sat Sun), &gettext("day_short_%{d}", d: &1))}
month_names={for m <- 1..12, do: Gettext.gettext(MyApp.Gettext, month_key(m))}
/>day_names is always ordered Monday-first; the component rotates it to match
starts_on.
Cell size
Day cells are square, and one CSS custom property sizes them:
--pc-calendar-cell-size, default 2.25rem. Set it on the calendar or on
anything above it and the days, the weekday headers and the month-nav arrows
all move together, so the grid stays in register:
<.calendar class="[--pc-calendar-cell-size:3rem]" />
<div style="--pc-calendar-cell-size: 4rem">
<.calendar />
</div>There is deliberately no size attr. The token is the API, the way
--pc-radius is for corners: one dial, set wherever you already set classes,
with no new attr values to learn or version. Being a class, it takes
breakpoint variants like any other - big cells cannot fit seven columns on a
phone, so a booking grid steps its size:
<.calendar class="[--pc-calendar-cell-size:2.75rem] sm:[--pc-calendar-cell-size:3.5rem]" />Custom day content
The :day slot replaces the content of every day button. The classic case is
a booking grid with a price under each date, which is a cell size and a slot
together:
<.calendar mode="range" value={@stay} class="[--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", !day.selected && "text-gray-500"]}>
{price_for(day.date)}
</span>
</span>
</:day>
</.calendar>The slot owns the whole content of the button, so you render the number. It receives the day the component already built:
:date- the%Date{}:iso- that date as an ISO 8601 string:today,:outside,:disabled,:selected- booleans:range_start,:range_middle,:range_end- booleans, range mode
The button itself is untouched: the selection chip, the range band, the today
dot and the disabled treatment are still the component's job, and the
aria-label is still the full date. The slot is what a sighted user sees
inside the button, not what a screen reader is told it is. Use :selected
to keep a muted second line readable once the inverse chip lands under it.
Summary
Functions
Renders a month grid. See the module docs above for the two wiring modes, the
selection shapes each mode accepts, and the gettext recipe for day and month
names.
Functions
Renders a month grid. See the module docs above for the two wiring modes, the
selection shapes each mode accepts, and the gettext recipe for day and month
names.
<.calendar value={@due_on} month={@month} on_select="pick" on_month_change="page" />Attributes
id(:string) - the calendar id; autogenerated if not set.mode(:string) - selection behaviour. Defaults to"single". Must be one of"single","range", or"multiple".value(:any) - 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). Defaults tonil.month(:any) - the displayed month as any Date within it; defaults to the selected value's month, else today. Defaults tonil.min(:any) - earliest selectable Date (inclusive). Defaults tonil.max(:any) - latest selectable Date (inclusive). Defaults tonil.disabled_dates(:any) - a list of Dates, or a 1-arity function Date -> boolean; disabled days render but are not selectable. Defaults tonil.today(:any) - the date treated as today; defaults to Date.utc_today/0. Pass the user's local date if UTC is not good enough. Defaults tonil.starts_on(:integer) - first day of the week, ISO day numbers (1 = Monday, 7 = Sunday). Defaults to1. Must be one of1,2,3,4,5,6, or7.show_outside_days(:boolean) - render leading/trailing days from adjacent months. Defaults totrue.fixed_weeks(:boolean) - always render 6 week rows so the grid height never jumps. Defaults tofalse.day_names(:list) - 7 short day labels ordered Monday-first; default English. Defaults to["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].month_names(:list) - 12 month labels; default English. Defaults to["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].day_names_long(:list) - 7 full day labels ordered Monday-first, used for screen readers; default English. Defaults to["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].on_select(:any) - event name pushed when a day is clicked; the payload carries the ISO date. Defaults tonil.on_month_change(:any) - event name for prev/next month; when nil, nav renders as patch links using the month_param query param. Defaults tonil.target(:any) - phx-target for the select and month-change events; set it to @myself when the calendar lives inside a LiveComponent. Defaults tonil.month_param(:string) - query param used for link-based month navigation. Defaults to"month".name(:any) - when set (and no on_select), day buttons submit this form field with the ISO date as value. Defaults tonil.nav(:string) - which month-nav arrows to render; two side-by-side panes want prev on the left pane and next on the right. Defaults to"both". Must be one of"both","prev","next", or"none".prev_label(:string) - accessible label for the prev button. Defaults to"Previous month".next_label(:string) - accessible label for the next button. Defaults to"Next month".class(:any) - extra classes for the calendar wrapper. Defaults tonil.- Global attributes are accepted.
Slots
day- 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.