calendar/date-picker.ex
Date picker
A month grid with a highlighted range and one selected day, for picking a date or a period.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.calendar_date_picker animated />
</div>
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
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
/* 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; }