empowered.house ↗

calendar/date-picker.ex

Date picker

A month grid with a highlighted range and one selected day, for picking a date or a period.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.calendar_date_picker animated />
</div>
default
isometric
another month · range across weeks
one day · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
year integer 2026
month integer 10 1 to 12. The default is a fixed month, not today.
selected any 16 The day of the month filled in lime, or `nil` for none.
range_from any 12 First day of the highlighted range, or `nil` for no range.
range_to any 16 Last day of the highlighted range.
today any 8 The day ringed as today, or `nil`.
caption any nil The footer line, up to 26 characters. Left empty, it is written from the range.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/calendar/date-picker.ex
date-picker.ex
defmodule EHVisuals.Calendar.DatePicker do
  @moduledoc """
  A date picker: one month as a grid, a highlighted range and the selected day.

  The grid is computed from the year and the month (first weekday, number of
  days, the days of the neighbouring months that fill the first and the last
  week), so any month draws correctly. Weeks start on Monday.
  """

  use EHVisuals.Composition,
    name: "calendar/date-picker",
    title: "Date picker",
    description:
      "A month grid with a highlighted range and one selected day, for picking a date or a period."

  @months ~w(January February March April May June July August September October November December)
  @weekdays ~w(Mo Tu We Th Fr Sa Su)

  @cell_w 40
  @row_h 34
  @grid_x 20
  @grid_y 84

  attr(:year, :integer, default: 2026)
  attr(:month, :integer, default: 10, doc: "1 to 12. The default is a fixed month, not today.")

  attr(:selected, :any,
    default: 16,
    doc: "The day of the month filled in lime, or `nil` for none."
  )

  attr(:range_from, :any,
    default: 12,
    doc: "First day of the highlighted range, or `nil` for no range."
  )

  attr(:range_to, :any, default: 16, doc: "Last day of the highlighted range.")
  attr(:today, :any, default: 8, doc: "The day ringed as today, or `nil`.")

  attr(:caption, :any,
    default: nil,
    doc: "The footer line, up to 26 characters. Left empty, it is written from the range."
  )

  attr(:label, :string,
    default: "A date picker for October 2026 with the 12th to the 16th highlighted"
  )

  frame_attrs()

  @spec calendar_date_picker(map()) :: Phoenix.LiveView.Rendered.t()
  def calendar_date_picker(assigns) do
    month = assigns.month |> max(1) |> min(12)
    first = Date.new!(assigns.year, month, 1)
    days = Date.days_in_month(first)
    offset = Date.day_of_week(first) - 1
    rows = div(offset + days + 6, 7)
    previous_days = first |> Date.add(-1) |> Map.fetch!(:day)
    range = range(assigns.range_from, assigns.range_to, days)
    month_name = Enum.at(@months, month - 1)

    cells =
      for index <- 0..(rows * 7 - 1) do
        day = index - offset + 1
        row = div(index, 7)
        col = rem(index, 7)

        {number, inside} =
          cond do
            day < 1 -> {previous_days + day, false}
            day > days -> {day - days, false}
            true -> {day, true}
          end

        %{
          number: number,
          inside: inside,
          row: row,
          col: col,
          cx: @grid_x + col * @cell_w + div(@cell_w, 2),
          cy: @grid_y + row * @row_h + div(@row_h, 2),
          selected: inside and number == assigns.selected,
          today: inside and number == assigns.today,
          in_range: inside and range != nil and number in range
        }
      end

    bands =
      cells
      |> Enum.filter(& &1.in_range)
      |> Enum.group_by(& &1.row)
      |> Enum.sort()
      |> Enum.map(fn {row, row_cells} ->
        {from, to} = row_cells |> Enum.map(& &1.col) |> Enum.min_max()

        %{
          row: row,
          x: @grid_x + from * @cell_w + 3,
          y: @grid_y + row * @row_h + 4,
          width: (to - from + 1) * @cell_w - 6
        }
      end)

    footer_y = @grid_y + rows * @row_h + 8

    assigns =
      assigns
      |> assign(:heading, "#{month_name} #{assigns.year}")
      |> assign(:weekdays, Enum.with_index(@weekdays))
      |> assign(:cells, cells)
      |> assign(:bands, bands)
      |> assign(:footer_y, footer_y)
      |> assign(:height, footer_y + 42)
      |> assign(:text, assigns.caption || caption(range, assigns.selected, month_name))
      |> assign(:count, count(range))

    ~H"""
    <.frame name="calendar/date-picker" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 320 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="319" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-t-strong" x="20" y="38" font-size="14.5">{@heading}</text>
            <rect class="ehv-card-2" x="244.5" y="20.5" width="24" height="24" rx="7" />
            <path class="ehv-line-ink ehv-round" d="M258.5 28 l-4 4.5 l4 4.5" />
            <rect class="ehv-card-2" x="275.5" y="20.5" width="24" height="24" rx="7" />
            <path class="ehv-line-ink ehv-round" d="M285.5 28 l4 4.5 l-4 4.5" />
          </g>
          <g class="ehv-a-fade ehv-i-2">
            <text
              :for={{name, index} <- @weekdays}
              class={["ehv-mono", index > 4 && "ehv-t-4"]}
              x={40 + index * 40}
              y="72"
              font-size="8"
              text-anchor="middle"
            >
              {String.upcase(name)}
            </text>
          </g>
          <rect
            :for={band <- @bands}
            class={"ehv-fill-lime-soft ehv-a-grow-x ehv-i-#{min(band.row + 7, 24)}"}
            x={band.x}
            y={band.y}
            width={band.width}
            height="26"
            rx="13"
          />
          <g :for={cell <- @cells} class={"ehv-a-fade ehv-i-#{min(cell.row + 3, 24)}"}>
            <circle
              :if={cell.today and not cell.selected}
              class="ehv-line"
              cx={cell.cx}
              cy={cell.cy}
              r="12.5"
            />
            <g :if={cell.selected} class="ehv-a-pop ehv-i-12">
              <circle
                class="ehv-fill-lime ehv-calendar-date-picker__selected"
                cx={cell.cx}
                cy={cell.cy}
                r="13"
              />
            </g>
            <text
              class={[
                cell.selected && "ehv-t-strong ehv-t-on-lime",
                not cell.inside && "ehv-t-4",
                (cell.inside and not cell.selected and not cell.in_range and cell.col > 4) &&
                  "ehv-t-3"
              ]}
              x={cell.cx}
              y={cell.cy + 4.2}
              font-size="12"
              text-anchor="middle"
            >
              {cell.number}
            </text>
          </g>
          <line class="ehv-line-faint" x1="20" x2="300" y1={@footer_y + 0.5} y2={@footer_y + 0.5} />
          <g class="ehv-a-fade ehv-i-10">
            <text class="ehv-t-2" x="20" y={@footer_y + 25} font-size="12">{@text}</text>
            <text
              :if={@count}
              class="ehv-mono ehv-t-lime"
              x="300"
              y={@footer_y + 24.5}
              font-size="8.5"
              text-anchor="end"
            >
              {String.upcase(@count)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # The highlighted days, clamped to the month, in either order.
  defp range(from, to, days) when is_integer(from) and is_integer(to) do
    {low, high} = Enum.min_max([from, to])
    max(low, 1)..min(high, days)//1
  end

  defp range(_from, _to, _days), do: nil

  defp caption(nil, nil, month), do: "Pick a day in #{month}"
  defp caption(nil, day, month), do: "#{day} #{month}"
  defp caption(range, _day, month) when range.first == range.last, do: "#{range.first} #{month}"
  defp caption(range, _day, month), do: "#{range.first} – #{range.last} #{month}"

  defp count(nil), do: nil
  defp count(range) when range.first > range.last, do: nil
  defp count(range) when range.first == range.last, do: "1 day"
  defp count(range), do: "#{range.last - range.first + 1} days"
end
assets/css/calendar/date-picker.css
date-picker.css
/* calendar/date-picker */
.ehv-calendar-date-picker { --ehv-max-w: 350px; --ehv-step: 70ms; }

.ehv--hover .ehv-calendar-date-picker__selected { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-calendar-date-picker__selected { r: 15; }