Calendar

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJS-moduleJSApache-2.0
Installation mix mishka.ui.gen.headless calendar
Source View Source

Overview of the Phoenix and LiveView Headless Calendar Component

The Mishka Chelekom Headless Calendar is a Phoenix function component that draws a month grid on the server. The weeks are Date arithmetic in Elixir, rendered as a real table with role="grid", so the month is correct before the LiveView socket connects and survives a reconnect. The only JavaScript is a small hook that moves focus with the arrow keys. The component ships no styling. The root, header, buttons, heading, grid, weekday names, weeks, cells and days each take a *_class attribute.

Three modes decide what a pick means: single selects one day, multiple selects several and range selects a start and an end. The days between the ends of a range are marked data-in-range, so you do not work out the middle yourself. Selection and the visible month belong to your LiveView. You pass value and month in and handle the on_select and on_month_change events to change them.

Base UI Calendar example - month grid with a selected day (Phoenix headless)

The preview on this page cannot pass Elixir Date values, so it shows the current month with today marked. The code below sets month, today and value to fixed dates.

Base UI Calendar example - month grid with a selected day (Phoenix headless)

October 2026
MoTuWeThFrSaSu
heex
<.calendar
  id="baseui-calendar-hero"
  month={~D[2026-03-01]}
  today={~D[2026-03-17]}
  value={~D[2026-03-12]}
  class="inline-flex flex-col gap-2 rounded-lg border border-neutral-200 bg-white p-3 text-neutral-950 dark:border-neutral-800 dark:bg-neutral-950 dark:text-white [&_[data-part=header]]:flex [&_[data-part=header]]:items-center [&_[data-part=header]]:justify-between [&_[data-part=heading]]:text-sm [&_[data-part=heading]]:font-medium [&_[data-part=weekday]]:p-1 [&_[data-part=weekday]]:text-xs [&_[data-part=weekday]]:font-normal [&_[data-part=weekday]]:text-neutral-500 [&_[data-part=previous]]:size-7 [&_[data-part=next]]:size-7 [&_[data-part=previous]]:rounded-md [&_[data-part=next]]:rounded-md [&_[data-part=previous]]:hover:bg-neutral-100 [&_[data-part=next]]:hover:bg-neutral-100 dark:[&_[data-part=previous]]:hover:bg-neutral-800 dark:[&_[data-part=next]]:hover:bg-neutral-800"
  day_class="grid size-8 place-items-center rounded-md text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 data-[outside]:text-neutral-400 dark:data-[outside]:text-neutral-600 aria-[current=date]:ring-1 aria-[current=date]:ring-neutral-950 dark:aria-[current=date]:ring-white data-[selected]:bg-neutral-950 data-[selected]:text-white dark:data-[selected]:bg-white dark:data-[selected]:text-neutral-950 aria-[disabled=true]:text-neutral-300 dark:aria-[disabled=true]:text-neutral-700"
/>

Base UI Calendar example - date range (Phoenix headless)

This example has no preview. A range is a {from, to} tuple of Date values, and a page marker can only pass text. The first and last day get data-selected, and every day between them gets data-in-range, which the classes below use for the band.

heex
<.calendar
  id="baseui-calendar-range"
  mode="range"
  month={~D[2026-03-01]}
  today={~D[2026-03-17]}
  value={{~D[2026-03-09], ~D[2026-03-18]}}
  class="inline-flex flex-col gap-2 rounded-lg border border-neutral-200 bg-white p-3 text-neutral-950 dark:border-neutral-800 dark:bg-neutral-950 dark:text-white [&_[data-part=header]]:flex [&_[data-part=header]]:items-center [&_[data-part=header]]:justify-between [&_[data-part=heading]]:text-sm [&_[data-part=heading]]:font-medium [&_[data-part=weekday]]:p-1 [&_[data-part=weekday]]:text-xs [&_[data-part=weekday]]:font-normal [&_[data-part=weekday]]:text-neutral-500 [&_[data-part=previous]]:size-7 [&_[data-part=next]]:size-7 [&_[data-part=previous]]:rounded-md [&_[data-part=next]]:rounded-md [&_[data-part=previous]]:hover:bg-neutral-100 [&_[data-part=next]]:hover:bg-neutral-100 dark:[&_[data-part=previous]]:hover:bg-neutral-800 dark:[&_[data-part=next]]:hover:bg-neutral-800"
  day_class="grid size-8 place-items-center rounded-md text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 data-[outside]:text-neutral-400 dark:data-[outside]:text-neutral-600 aria-[current=date]:ring-1 aria-[current=date]:ring-neutral-950 dark:aria-[current=date]:ring-white data-[selected]:bg-neutral-950 data-[selected]:text-white dark:data-[selected]:bg-white dark:data-[selected]:text-neutral-950 aria-[disabled=true]:text-neutral-300 dark:aria-[disabled=true]:text-neutral-700 data-[in-range]:rounded-none data-[in-range]:bg-neutral-100 dark:data-[in-range]:bg-neutral-800"
/>

Base UI Calendar example - Sunday start without outside days (Phoenix headless)

Base UI Calendar example - Sunday start without outside days (Phoenix headless)

October 2026
SuMoTuWeThFrSa
heex
<.calendar
  id="baseui-calendar-sunday"
  first_day_of_week={7}
  show_outside_days={false}
  fixed_weeks={false}
  class="inline-flex flex-col gap-2 rounded-lg border border-neutral-200 bg-white p-3 text-neutral-950 dark:border-neutral-800 dark:bg-neutral-950 dark:text-white [&_[data-part=header]]:flex [&_[data-part=header]]:items-center [&_[data-part=header]]:justify-between [&_[data-part=heading]]:text-sm [&_[data-part=heading]]:font-medium [&_[data-part=weekday]]:p-1 [&_[data-part=weekday]]:text-xs [&_[data-part=weekday]]:font-normal [&_[data-part=weekday]]:text-neutral-500 [&_[data-part=previous]]:size-7 [&_[data-part=next]]:size-7 [&_[data-part=previous]]:rounded-md [&_[data-part=next]]:rounded-md [&_[data-part=previous]]:hover:bg-neutral-100 [&_[data-part=next]]:hover:bg-neutral-100 dark:[&_[data-part=previous]]:hover:bg-neutral-800 dark:[&_[data-part=next]]:hover:bg-neutral-800"
  day_class="grid size-8 place-items-center rounded-md text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 data-[outside]:text-neutral-400 dark:data-[outside]:text-neutral-600 aria-[current=date]:ring-1 aria-[current=date]:ring-neutral-950 dark:aria-[current=date]:ring-white data-[selected]:bg-neutral-950 data-[selected]:text-white dark:data-[selected]:bg-white dark:data-[selected]:text-neutral-950 aria-[disabled=true]:text-neutral-300 dark:aria-[disabled=true]:text-neutral-700"
/>

Modes and values

mode is single by default. In single mode value is one Date. In multiple mode it is a list of Date values. In range mode it is a {from, to} tuple, or {from, nil} while only the first day is picked. If you leave month out, the grid shows the month of the first selected day, or the current month when nothing is selected.

In a range, the first and last day get data-selected together with data-range-start or data-range-end. Every day between them gets data-in-range.

Events and server state

The calendar keeps no state of its own. on_select names the LiveView event pushed when a day is picked. It carries the date as an ISO 8601 string under date. on_month_change names the event pushed when the month is paged. It carries an ISO 8601 date in the target month under month. Both arrive as string-keyed params.

heex
<.calendar
  id="booking-calendar"
  month={@month}
  value={@date}
  on_select="pick_date"
  on_month_change="change_month"
/>
elixir
def handle_event("pick_date", %{"date" => date}, socket) do
  case Date.from_iso8601(date) do
    {:ok, date} -> {:noreply, assign(socket, :date, date)}
    {:error, _reason} -> {:noreply, socket}
  end
end

def handle_event("change_month", %{"month" => month}, socket) do
  case Date.from_iso8601(month) do
    {:ok, month} -> {:noreply, assign(socket, :month, month)}
    {:error, _reason} -> {:noreply, socket}
  end
end

Limits and disabled days

min and max take a Date. Days outside them get aria-disabled="true" and push no event. The previous and next buttons become disabled when paging would leave that range. disabled_dates takes a list of single Date values that cannot be picked. Unavailable days use aria-disabled rather than the disabled attribute, so the arrow keys still move through them.

Localisation and week start

first_day_of_week picks the first column, from 1 for Monday to 7 for Sunday. month_names takes twelve names, January first. weekday_names and weekday_abbreviations take seven names, Monday first. The component rotates the weekday lists to match first_day_of_week, so you always write them Monday first. The day buttons get long labels built from these names, such as Monday, 3 March 2025. label is written to the grid's aria-label. previous_label and next_label are the accessible names of the two paging buttons.

fixed_weeks keeps the grid at six rows so its height does not change between months. show_outside_days controls whether days from the neighbouring months are drawn. When drawn, they are marked data-outside. The generated module also exposes a public weeks/3 function that returns the weeks of a month as lists of seven dates, in case you need the same layout somewhere else.

Parts & data attributes

The root carries data-part="root", data-month (the first day of the shown month), data-mode, data-on-select and data-on-month-change. The parts are header, previous, next, heading, grid, weekday, week, cell and day. Their class attributes are header_class, control_class (both paging buttons), heading_class, grid_class, weekday_class, week_class, cell_class and day_class.

Each day is a button with role="gridcell" and a data-date in ISO 8601. It also carries data-today, data-outside, data-selected, data-range-start, data-range-end and data-in-range when they apply, plus aria-selected, aria-current="date" for today and aria-disabled for unavailable days. Target these in Tailwind with variants such as data-[selected]: and aria-[current=date]:.

Accessibility

Follows the WAI-ARIA Grid pattern. Only one day is in the tab order: the selected day when it is on screen, otherwise today, otherwise the first of the month. So the whole calendar is one tab stop.

Arrow Left and Arrow Right move by a day, and Arrow Up and Arrow Down move by a week. Home and End go to the first and last day of the week. Page Up and Page Down move to the same day in the previous or next month. When that month is shorter, focus lands on its last day, so Page Down from 31 January goes to 28 or 29 February. When a key leads out of the rendered month, the hook asks your LiveView for the neighbouring month through on_month_change and puts focus on the right date once it arrives.

The heading is a polite live region, so a month change is announced. Each weekday header carries an abbr with the full name, and each day button has a full date as its accessible name.