Components Calendar

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

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.

March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

Book a consultation
Weekdays only, 45 minutes.
March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

Book your appointment
30 minutes, on a video call.
March 2026
March 2026
Monday Tuesday Wednesday Thursday Friday Saturday Sunday
heex
<.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.

February 2026
February 2026
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
heex
<.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