Calendar

Always-visible calendar grid for single dates, multiple dates, or ranges, at day, month, or year granularity.

Basic

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar name="date" />

With label and help text

Required

Pick the day you would like to come in.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
We will email a reminder the morning of.
.heex
<.calendar
  name="appointment"
  label="Appointment date"
  sublabel="Required"
  description="Pick the day you would like to come in."
  help_text="We will email a reminder the morning of."
/>

Pre-selected value

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar name="preselected_date" value={Date.utc_today()} />

Multiple selection

Add multiple to select more than one date. Each pick renders its own hidden input, so name should end in [].

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar name="dates[]" multiple />

Range selection

Use calendar_range to pick a start and end date. The endpoints render with a connecting band across the days in between.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar_range
  start_name="check_in"
  end_name="check_out"
  start_value={Date.utc_today()}
  end_value={Date.add(Date.utc_today(), 5)}
/>

Min and max bounds

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
Within the next 30 days.
.heex
<.calendar
  name="deadline"
  min={Date.utc_today()}
  max={Date.add(Date.utc_today(), 30)}
/>

Disabled dates

disabled_dates takes a mixed list of specific dates and recurring patterns: :weekends, {:weekday, n}, {:month_day, m, d}, and Date.Range values.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar
  name="visit"
  disabled_dates={[
    :weekends,
    Date.add(Date.utc_today(), 3),
    {:month_day, 12, 25}
  ]}
/>

Required selection

allow_deselect={false} keeps at least one date selected; clicking the active day no longer clears it.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar
  name="required_date"
  value={Date.utc_today()}
  allow_deselect={false}
/>

Month granularity

2026
.heex
<.calendar name="month" granularity="month" />

Year granularity

2020 - 2031
.heex
<.calendar name="year" granularity="year" />

Extended navigation

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar name="extended_nav_date" navigation="extended" />

Select navigation

navigation="select" swaps the header arrows for month and year dropdowns, so jumping across many months takes one click.

Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar name="select_nav_date" navigation="select" />

Localized

Julio 2026
Lun
Mar
Mié
Jue
Vie
Sáb
Dom
.heex
<.calendar name="fecha" locale="es" week_start={1} />

Static

static renders a read-only grid with no header, no hidden inputs, and no JS hook. Selected dates stay highlighted and every cell is unfocusable.

See you on the highlighted date.

Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar
  name="static_appointment"
  label="Confirmed appointment"
  description="See you on the highlighted date."
  value={Date.utc_today()}
  static
/>

Form integration with validation

Pass a Phoenix.HTML.FormField via field to derive name, value, and errors automatically. Server-side validation rules surface as inline errors below the calendar.

Pick a weekday on or after today.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
Changes: %{}
.heex
<.form :let={f} for={@form} phx-change="validate">
  <.calendar
    field={f[:date]}
    label="Appointment"
    min={Date.utc_today()}
  />
</.form>
.ex
def handle_event("validate", %{"data" => _params}, socket) do
  # Re-run validation and reassign the form
  {:noreply, socket}
end

Server-driven updates

Update value from the server and the calendar re-syncs its selection without a full re-render. The grid uses phx-update="ignore"; the hidden input value bridges patches into the JS hook.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
Push from the server
Server value
2026-07-08
Wednesday, July 8, 2026
.heex
<.calendar name="appointment" value={@value} />

<.button phx-click={JS.push("set_today")}>Set today</.button>
<.button phx-click={JS.push("shift", value: %{days: 7})}>+7 days</.button>
.ex
def handle_event("set_today", _, socket) do
  {:noreply, assign(socket, :value, Date.utc_today())}
end

def handle_event("shift", %{"days" => days}, socket) do
  {:noreply, update(socket, :value, &Date.add(&1, days))}
end

Dynamic constraints

Push new min and max bounds from the server. The calendar's mutation observer picks up the change and updates which days are selectable and which navigation arrows are enabled.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
Window: Jul 8 to Aug 7
.heex
<.button phx-click={JS.push("set_window", value: %{window: "week"})}>Next 7 days</.button>
<.button phx-click={JS.push("set_window", value: %{window: "month"})}>Next 30 days</.button>

<.calendar name="date" min={@min} max={@max} />
.ex
def handle_event("set_window", %{"window" => _window}, socket) do
  # Compute the new min/max for the chosen window and reassign
  {:noreply, socket}
end

Server-fetched event markers

Fetch event markers from the server every time the user navigates to a new month. A small hook bridges the calendar's navigate event into a LiveView handler, a backdrop overlay covers the calendar while the request is in flight, and a dot is painted under each returned date.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<div
  id={@id}
  phx-hook="CalendarNavigate"
  data-marker-dates={Enum.join(@events, ",")}
  class={[
    "relative max-w-sm",
    "**:data-has-marker:relative",
    "**:data-has-marker:after:content-['']",
    "**:data-has-marker:after:absolute",
    "**:data-has-marker:after:bottom-1",
    "**:data-has-marker:after:left-1/2",
    "**:data-has-marker:after:size-1",
    "**:data-has-marker:after:-translate-x-1/2",
    "**:data-has-marker:after:rounded-full",
    "**:data-has-marker:after:bg-emerald-500",
    "**:data-has-marker:after:pointer-events-none"
  ]}
>
  <.calendar id={@id <> "-cal"} name="date" />

  <div
    aria-hidden="true"
    class={[
      "absolute inset-0 z-20 flex items-center justify-center rounded-base",
      "bg-overlay/60 backdrop-blur-xs transition-opacity duration-150",
      "opacity-0 pointer-events-none",
      "phx-hook-loading:opacity-100 phx-hook-loading:pointer-events-auto"
    ]}
  >
    <.loading variant="dots-bounce" class="size-6 text-foreground" />
  </div>
</div>
.js
const CalendarNavigate = {
  mounted() {
    this.navigateHandler = (event) => {
      this.pushEventTo(this.el, "calendar_navigate", { date: event.detail.date });
    };
    this.el.addEventListener("fluxon:calendar:navigate", this.navigateHandler);
    this.applyMarkers();
  },
  updated() {
    this.applyMarkers();
  },
  destroyed() {
    this.el.removeEventListener("fluxon:calendar:navigate", this.navigateHandler);
  },
  applyMarkers() {
    const dates = (this.el.dataset.markerDates || "").split(",").filter(Boolean);
    const set = new Set(dates);
    this.el.querySelectorAll("[data-date]").forEach((cell) => {
      cell.toggleAttribute("data-has-marker", set.has(cell.dataset.date));
    });
  },
};
.ex
def handle_event("calendar_navigate", %{"date" => date}, socket) do
  Process.sleep(200)
  {:noreply, assign(socket, events: fetch_events_for_month(date))}
end

Birth date picker

Combine navigation="select" with max={Date.utc_today()} to jump across years quickly while blocking any future date.

Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar
  name="birth_date"
  label="Date of birth"
  navigation="select"
  max={Date.utc_today()}
/>

Time off request

A date range constrained to upcoming weekdays via min and disabled_dates={[:weekends]}, with navigation="extended" adding year arrows to the header.

Working days only.

July 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
.heex
<.calendar_range
  start_name="leave_start"
  end_name="leave_end"
  label="Time off"
  description="Working days only."
  min={Date.utc_today()}
  disabled_dates={[:weekends]}
  navigation="extended"
/>