Calendar
A headless date picker with single, multiple, and range selection. Supports day, month, and year views with full keyboard navigation.
Single
Installation
biji-ui = { version = "0.5.0", features = ["calendar"] }
Usage
use leptos::prelude::*;
use biji_ui::components::calendar;
#[component]
pub fn MyCalendar() -> impl IntoView {
view! {
<calendar::Root
selection_type={calendar::SelectionType::Single}
months=1
week_starts_on={calendar::WeekStartsOn::Sunday}
>
<calendar::Header class="flex justify-between items-center mb-3">
<calendar::PrevButton class="flex justify-center items-center w-7 h-7 text-sm rounded-md hover:bg-muted">
"‹"
</calendar::PrevButton>
<calendar::Heading class="text-sm font-medium cursor-pointer" />
<calendar::NextButton class="flex justify-center items-center w-7 h-7 text-sm rounded-md hover:bg-muted">
"›"
</calendar::NextButton>
</calendar::Header>
<calendar::Grid>
<calendar::GridHead class="grid grid-cols-7 mb-1 text-xs text-center text-muted-foreground" />
<calendar::GridBody
day_class="grid grid-cols-7 gap-y-1 [&_button]:aspect-square"
month_class="grid grid-cols-4 gap-1 [&_button]:py-2"
year_class="grid grid-cols-5 gap-1 [&_button]:py-2"
/>
</calendar::Grid>
</calendar::Root>
}
}
RootWith
Use <RootWith> when you need direct access to CalendarState inside the children. The let:cal binding exposes cal.value, cal.view, and cal.placeholder as reactive signals — no callbacks needed to read the current selection.
RootWith — selected date displayed inline
No date selected
No hover date
use leptos::prelude::*;
use biji_ui::components::calendar::{self, CalendarValue};
#[component]
pub fn MyCalendar() -> impl IntoView {
view! {
<calendar::RootWith selection_type={calendar::SelectionType::Single} let:cal>
<calendar::Header class="flex justify-between items-center mb-3">
<calendar::PrevButton class="flex justify-center items-center w-7 h-7 text-sm rounded-md hover:bg-muted">
"‹"
</calendar::PrevButton>
<calendar::Heading class="text-sm font-medium cursor-pointer" />
<calendar::NextButton class="flex justify-center items-center w-7 h-7 text-sm rounded-md hover:bg-muted">
"›"
</calendar::NextButton>
</calendar::Header>
<calendar::Grid>
<calendar::GridHead class="grid grid-cols-7 mb-1 text-xs text-center text-muted-foreground" />
<calendar::GridBody
day_class="grid grid-cols-7 gap-y-1 [&_button]:aspect-square"
month_class="grid grid-cols-4 gap-1 [&_button]:py-2"
year_class="grid grid-cols-5 gap-1 [&_button]:py-2"
/>
</calendar::Grid>
<div class="flex flex-col gap-0.5 justify-center items-center mt-3 h-8">
<p class="text-sm text-center text-muted-foreground">
{move || cal.value.with(|v| match v {
CalendarValue::Single(Some(d)) => d.format("%-d %B %Y").to_string(),
_ => "No date selected".to_string(),
})}
</p>
<p class="text-xs text-center text-muted-foreground/60">
{move || match cal.hover_date.get() {
Some(d) => d.format("Hovering: %-d %B %Y").to_string(),
None => "No hover date".to_string(),
}}
</p>
</div>
</calendar::RootWith>
}
}
Examples
Range
Pass SelectionType::Range to enable range selection. Clicking once sets the start date; clicking again sets the end. A hover preview highlights the candidate range before the second click.
Range
// Use CalendarValue::Range as the initial value.
let value = RwSignal::new(calendar::CalendarValue::Range {
start: None,
end: None,
});
view! {
<calendar::Root
value={value}
selection_type={calendar::SelectionType::Range}
>
// ... (same Header and Grid structure as a single calendar)
</calendar::Root>
}
Multi-month
Set months=2 on Root and render one Grid per month with month_offset. All grids share a single navigation, and Heading automatically shows the full span (e.g. "March – April 2026").
Multi-month
// Set months=2 on Root, then render one Grid per month.
<calendar::Root months=2 selection_type={calendar::SelectionType::Range}>
<calendar::Header class="flex justify-between items-center mb-3">
<calendar::PrevButton>{"‹"}</calendar::PrevButton>
// Heading automatically shows "March – April 2026" when months > 1.
<calendar::Heading />
<calendar::NextButton>{"›"}</calendar::NextButton>
</calendar::Header>
<div class="flex gap-6">
<calendar::Grid month_offset=0>
<calendar::GridHead />
<calendar::GridBody />
</calendar::Grid>
<calendar::Grid month_offset=1>
<calendar::GridHead />
<calendar::GridBody />
</calendar::Grid>
</div>
</calendar::Root>
Controlled
The value signal is owned by the parent and can be written to at any time. Components inside Root can also access CalendarState directly to navigate the displayed month alongside the value change.
Controlled
// The parent owns the value signal and writes to it directly.
let today = chrono::Local::now().date_naive();
let value = RwSignal::new(calendar::CalendarValue::Single(None));
// NavButtons must live inside <calendar::Root> to access CalendarState,
// which lets it also navigate the displayed month when setting the value.
#[component]
fn NavButtons(value: RwSignal<calendar::CalendarValue>) -> impl IntoView {
use biji_ui::components::calendar::{CalendarState, CalendarValue};
use chrono::Datelike;
let ctx = expect_context::<CalendarState>();
let today = chrono::Local::now().date_naive();
let ly = today.with_year(today.year() - 1).unwrap_or(today);
let lw = today.checked_sub_signed(chrono::Duration::weeks(1)).unwrap_or(today);
let nw = today.checked_add_signed(chrono::Duration::weeks(1)).unwrap_or(today);
let ny = today.with_year(today.year() + 1).unwrap_or(today);
view! {
<button on:click=move |_| {
value.set(CalendarValue::Single(Some(ly)));
ctx.placeholder.set(ly.with_day(1).unwrap_or(ly));
}>"Last Year"</button>
// ... Last Week, Today, Next Week, Next Year follow the same pattern
}
}
view! {
<calendar::Root value={value} selection_type={calendar::SelectionType::Single}>
<NavButtons value={value} />
// ... header and grid
</calendar::Root>
}
Date constraints
Use min_date and max_date to restrict the selectable range. Dates outside the range are rendered with data-disabled and cannot be clicked or keyboard-navigated to.
Date constraints
use chrono::Duration;
let today = chrono::Local::now().date_naive();
let week_ago = today.checked_sub_signed(Duration::weeks(1)).unwrap_or(today);
view! {
<calendar::Root
min_date={week_ago}
max_date={today}
selection_type={calendar::SelectionType::Single}
>
// ... header and grid
</calendar::Root>
}
Custom disabled dates
For arbitrary rules use is_date_disabled. The predicate receives each date and returns true to disable it. Here weekends (Saturday and Sunday) are disabled.
Custom disabled dates
// Disable weekends (Saturday and Sunday).
<calendar::Root
selection_type={calendar::SelectionType::Single}
is_date_disabled={Box::new(|date: chrono::NaiveDate| {
use chrono::Datelike;
matches!(date.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun)
})}
>
// ... header and grid
</calendar::Root>
API Reference
Root / RootWith
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the root element. |
| selection_type | SelectionType | Single | Controls whether a single date, multiple dates, or a date range can be selected. |
| months | usize | 1 | Number of month grids to display side-by-side. All grids share the same navigation. |
| week_starts_on | WeekStartsOn | Sunday | Which day is treated as the first column in the week grid. |
| value | Option<RwSignal<CalendarValue>> | None | Controlled value signal. When provided the calendar is controlled externally. |
| default_value | Option<CalendarValue> | None | Initial value for uncontrolled mode. |
| placeholder | Option<NaiveDate> | today | Overrides the initially displayed month. Pass this from the server to avoid SSR hydration mismatches near timezone boundaries. |
| min_date | Option<NaiveDate> | None | Dates before this are disabled and unselectable. |
| max_date | Option<NaiveDate> | None | Dates after this are disabled and unselectable. |
| is_date_disabled | Option<Box<dyn Fn(NaiveDate) -> bool + Send + Sync>> | None | Custom predicate — return true to disable a specific date. Must be Send + Sync. |
| on_change | Option<Callback<CalendarValue>> | None | Called whenever the selection changes. In controlled mode the external value signal is already updated before this fires — prefer reacting to the signal directly to avoid double-notification. Use on_change for out-of-band side effects such as persisting to a server. |
Header
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the header wrapper div. |
PrevButton
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the previous-navigation button. |
NextButton
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the next-navigation button. |
Heading
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the heading button. Clicking it cycles through Day → Month → Year views. |
Grid
| Name | Type | Default | Description |
|---|---|---|---|
| month_offset | usize | 0 | Which month to display, offset from the anchor month. Use 0, 1, 2, … for multi-month layouts. |
| class | String | "" | CSS class applied to the grid wrapper. |
GridHead
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class applied to the weekday header row. Invisible in Month and Year views (visibility: hidden) so it still occupies space and prevents layout shift. |
GridBody
| Name | Type | Default | Description |
|---|---|---|---|
| class | String | "" | CSS class always applied to the body container (all views). |
| day_class | String | "" | Additional class applied when showing the day grid (view = Day). Apply grid grid-cols-7 here. Each week row is wrapped in a role="row" element with display:contents so column layout is inherited from this class. |
| month_class | String | "" | Additional class applied when showing the month picker (view = Month). Apply grid grid-cols-4 here. |
| year_class | String | "" | Additional class applied when showing the year picker (view = Year). Apply grid grid-cols-5 here for the 5-column decade grid. |
Data Attributes
| Attribute | Description |
|---|---|
| data-view | On Root: "day", "month", or "year" — the current picker view. |
| data-today | On day buttons: present when the date is today. |
| data-selected | On day / month / year buttons: present when that cell is part of the current selection. |
| data-disabled | On day buttons: present when the date is disabled via min_date, max_date, or is_date_disabled. |
| data-in-range | On day buttons (Range mode): present for dates between the range start and end, including hover preview. |
| data-range-start | On day buttons (Range mode): present on the selected range start date. |
| data-range-end | On day buttons (Range mode): present on the selected range end date. |
| data-current-month | On month buttons (Month view): present for the current calendar month. |
| data-current-year | On year buttons (Year view): present for the current calendar year. |
Keyboard Navigation
| Key | Description |
|---|---|
| ArrowLeft / ArrowRight | Day: ±1 day. Month: ±1 month (wraps into adjacent year). Year: ±1 year (wraps into adjacent decade). |
| ArrowUp / ArrowDown | Day: ±1 week. Month: ±4 months (one row). Year: ±5 years (one row). |
| Home | Day: first day of month. Month: January of displayed year. Year: first year of the decade window. |
| End | Day: last day of month. Month: December of displayed year. Year: last year of the decade window. |
| PageUp / PageDown | Day: previous/next month. Month: same month, previous/next year. Year: previous/next decade. |
| Enter / Space | Day: select the focused date. Month: drill into the focused month (switches to Day view). Year: drill into the focused year (switches to Month view). |