Biji UI

Calendar

A headless date picker with single, multiple, and range selection. Supports day, month, and year views with full keyboard navigation.

Single

Su
Mo
Tu
We
Th
Fr
Sa

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

Su
Mo
Tu
We
Th
Fr
Sa

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

Su
Mo
Tu
We
Th
Fr
Sa
// 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

Su
Mo
Tu
We
Th
Fr
Sa
Su
Mo
Tu
We
Th
Fr
Sa
// 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

Su
Mo
Tu
We
Th
Fr
Sa
// 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

Su
Mo
Tu
We
Th
Fr
Sa
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

Su
Mo
Tu
We
Th
Fr
Sa
// 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

NameTypeDefaultDescription
classString""CSS class applied to the root element.
selection_typeSelectionTypeSingleControls whether a single date, multiple dates, or a date range can be selected.
monthsusize1Number of month grids to display side-by-side. All grids share the same navigation.
week_starts_onWeekStartsOnSundayWhich day is treated as the first column in the week grid.
valueOption<RwSignal<CalendarValue>>NoneControlled value signal. When provided the calendar is controlled externally.
default_valueOption<CalendarValue>NoneInitial value for uncontrolled mode.
placeholderOption<NaiveDate>todayOverrides the initially displayed month. Pass this from the server to avoid SSR hydration mismatches near timezone boundaries.
min_dateOption<NaiveDate>NoneDates before this are disabled and unselectable.
max_dateOption<NaiveDate>NoneDates after this are disabled and unselectable.
is_date_disabledOption<Box<dyn Fn(NaiveDate) -> bool + Send + Sync>>NoneCustom predicate — return true to disable a specific date. Must be Send + Sync.
on_changeOption<Callback<CalendarValue>>NoneCalled 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

NameTypeDefaultDescription
classString""CSS class applied to the header wrapper div.

PrevButton

NameTypeDefaultDescription
classString""CSS class applied to the previous-navigation button.

NextButton

NameTypeDefaultDescription
classString""CSS class applied to the next-navigation button.

Heading

NameTypeDefaultDescription
classString""CSS class applied to the heading button. Clicking it cycles through Day → Month → Year views.

Grid

NameTypeDefaultDescription
month_offsetusize0Which month to display, offset from the anchor month. Use 0, 1, 2, … for multi-month layouts.
classString""CSS class applied to the grid wrapper.

GridHead

NameTypeDefaultDescription
classString""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

NameTypeDefaultDescription
classString""CSS class always applied to the body container (all views).
day_classString""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_classString""Additional class applied when showing the month picker (view = Month). Apply grid grid-cols-4 here.
year_classString""Additional class applied when showing the year picker (view = Year). Apply grid grid-cols-5 here for the 5-column decade grid.

Data Attributes

AttributeDescription
data-viewOn Root: "day", "month", or "year" — the current picker view.
data-todayOn day buttons: present when the date is today.
data-selectedOn day / month / year buttons: present when that cell is part of the current selection.
data-disabledOn day buttons: present when the date is disabled via min_date, max_date, or is_date_disabled.
data-in-rangeOn day buttons (Range mode): present for dates between the range start and end, including hover preview.
data-range-startOn day buttons (Range mode): present on the selected range start date.
data-range-endOn day buttons (Range mode): present on the selected range end date.
data-current-monthOn month buttons (Month view): present for the current calendar month.
data-current-yearOn year buttons (Year view): present for the current calendar year.

Keyboard Navigation

KeyDescription
ArrowLeft / ArrowRightDay: ±1 day. Month: ±1 month (wraps into adjacent year). Year: ±1 year (wraps into adjacent decade).
ArrowUp / ArrowDownDay: ±1 week. Month: ±4 months (one row). Year: ±5 years (one row).
HomeDay: first day of month. Month: January of displayed year. Year: first year of the decade window.
EndDay: last day of month. Month: December of displayed year. Year: last year of the decade window.
PageUp / PageDownDay: previous/next month. Month: same month, previous/next year. Year: previous/next decade.
Enter / SpaceDay: select the focused date. Month: drill into the focused month (switches to Day view). Year: drill into the focused year (switches to Month view).