Range Calendar
Pick a span of days from a month grid.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
26 | 27 | 28 | 29 | 30 | 31 | 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 1 | 2 | 3 | 4 | 5 |
import { signal } from "@implementjs/core";
import { today, type DateRange } from "@implementjs/primitives";
import { RangeCalendar } from "@/lib/components/ui/range-calendar";
export default function RangeCalendarDemo() {
const value = signal<DateRange>({
start: today(),
end: today().add({ days: 4 }),
});
return RangeCalendar({ value, calendarLabel: "Trip dates" });
}A range calendar is a calendar that selects a span of days instead of a single date. The parts and layout are the same — the root takes a render function receiving months and weekdays — with RangeCalendarCell and RangeCalendarDay carrying the extra range state:
import {
RangeCalendar,
RangeCalendarCell,
RangeCalendarDay,
RangeCalendarGrid,
RangeCalendarGridBody,
RangeCalendarGridHead,
RangeCalendarGridRow,
RangeCalendarHeadCell,
RangeCalendarHeader,
RangeCalendarHeading,
RangeCalendarNextButton,
RangeCalendarPrevButton,
} from "@implementjs/primitives";
import { ForEach, Fragment } from "@implementjs/core";
RangeCalendar({ calendarLabel: "Trip dates" }, ({ months, weekdays }) =>
Fragment(
RangeCalendarHeader(
RangeCalendarPrevButton("←"),
RangeCalendarHeading(),
RangeCalendarNextButton("→"),
),
ForEach(
months,
(month) => month.value.toString(),
(month) =>
RangeCalendarGrid(
RangeCalendarGridHead(
RangeCalendarGridRow(
ForEach(
weekdays,
(_, i) => i,
(weekday) => RangeCalendarHeadCell(weekday),
),
),
),
RangeCalendarGridBody(
ForEach(
month.bind((m) => m.weeks),
(week) => week[0].toString(),
(week) =>
RangeCalendarGridRow(
ForEach(
week,
(date) => date.toString(),
(date) => RangeCalendarCell({ date, month }, RangeCalendarDay()),
),
),
),
),
),
),
),
);
Ranges spanning several months read best with numberOfMonths: 2 — the render function receives both months, and the nav buttons page past them.
Value
The value is a DateRange — { start, end } of CalendarDate | null. Pass an object to seed it, or a signal to control it from outside:
const value = signal<DateRange>({ start: null, end: null });
RangeCalendar({ value }, ({ months, weekdays }) => /* ... */);
The first click sets start; while the end is undecided, hovering (or arrowing) highlights the prospective span with data-highlighted. The second click sets end — clicking before the start swaps the two so the range always runs forward, and a write of an inverted range from outside is reordered the same way. With a complete range, the next click starts a new one; clicking the end date again clears the selection (unless preventDeselect is set).
Constraints
minDays/maxDaysbound the span's length. A pick outside the bounds restarts the selection at the clicked date.excludeDisabledclears any range that would contain a disabled date; while selecting, an invalid span simply doesn't highlight, and completing across one restarts at the clicked date.minValue,maxValue,isDateDisabled, andisDateUnavailablework exactly as on the calendar.
onRangeSelect runs whenever both ends become set.
Keyboard, navigation, i18n
Identical to the calendar: arrow keys move the focused day and page across months, Enter and Space select, prev/next buttons and the month/year selects navigate, and locale drives all formatting. Moving focus with the keyboard also drives the range highlight.
Styling
Parts set data-range-calendar-* attributes (data-range-calendar-root, data-range-calendar-day, …). On top of the shared cell state (data-selected, data-today, data-focused, data-outside-month, data-disabled, data-unavailable, data-value), cells and days expose the range shape:
RangeCalendarDay({
class:
"size-8 data-selection-start:bg-primary data-selection-end:bg-primary data-range-middle:bg-accent data-highlighted:bg-accent",
});
data-selection-start/data-selection-endmark the ends.data-range-start/data-range-endmark the visual edges even while the end is unset.data-range-middlemarks days strictly inside a complete range.data-highlightedmarks the prospective span under the pointer or keyboard focus.
API Reference
RangeCalendar
The root. Owns the selected range and the visible months, and calls its children render function with { months, weekdays }. Sets role="application" and a full aria-label. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<DateRange> | DateRange | { start: null, end: null } | The selected range. Inverted writes are reordered. Pass a signal to control it from outside. |
minDays | number | — | The fewest days a range may span. Shorter picks restart the selection. |
maxDays | number | — | The most days a range may span. Longer picks restart the selection. |
excludeDisabled | boolean | false | Clear the range if it would contain a disabled date. |
onRangeSelect | () => void | — | Runs after both ends of the range are selected. |
placeholder | Signal<CalendarDate> | CalendarDate | today | The date the view starts on and keyboard focus follows. Pass a signal to control the view from outside. |
minValue | CalendarDate | — | The earliest selectable date. Earlier dates are disabled. |
maxValue | CalendarDate | — | The latest selectable date. Later dates are disabled. |
isDateDisabled | (date: CalendarDate) => boolean | — | Marks dates as disabled: not selectable and skipped by the keyboard. |
isDateUnavailable | (date: CalendarDate) => boolean | — | Marks dates as unavailable: focusable and rendered, but not selectable. Sets data-unavailable. |
preventDeselect | boolean | false | Whether clicking a selected date again keeps it selected. |
disabled | Signal<boolean> | boolean | false | Disables the whole calendar. |
readonly | Signal<boolean> | boolean | false | The value can be read but not changed. |
fixedWeeks | boolean | false | Always render six weeks per month so the grid height never changes. |
numberOfMonths | number | 1 | How many consecutive months are rendered. |
pagedNavigation | boolean | false | Whether prev/next move by numberOfMonths months instead of one. |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — | The day the week starts on, 0 being Sunday. Defaults to the locale's week start where the runtime knows it. |
weekdayFormat | "narrow" | "short" | "long" | "narrow" | The Intl width of the weekday names handed to the render function. |
disableDaysOutsideMonth | boolean | true | Whether the leading and trailing days outside the month are disabled. |
locale | string | "en-US" | BCP 47 locale tag used for all formatting. |
calendarLabel | string | "Event" | Prefixed onto the visible month to label the calendar for assistive technology. |
monthFormat | Intl.DateTimeFormatOptions["month"] | ((month: number) => string) | "long" | How the heading and month select format month names. |
yearFormat | Intl.DateTimeFormatOptions["year"] | ((year: number) => string) | "numeric" | How the heading and year select format years. |
| Data attribute | Value |
|---|---|
[data-range-calendar-root] | Present |
[data-invalid] | Present when an end is disabled/unavailable or the range is inverted |
[data-disabled] | Present when disabled |
[data-readonly] | Present when readonly |
RangeCalendarCell
One grid cell. Sets role="gridcell" plus aria-selected/aria-disabled, and computes the day's range state for everything inside it. Renders a Td; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
date* | CalendarDate | Readable<CalendarDate> | — | The date this cell renders. |
month* | CalendarDate | Month | Readable<CalendarDate | Month> | — | The month whose grid the cell sits in — pass the render function's month straight through. |
| Data attribute | Value |
|---|---|
[data-range-calendar-cell] | Present |
[data-value] | The cell's date as YYYY-MM-DD |
[data-selected] | Present when selected |
[data-focused] | Present when the placeholder is this date |
[data-today] | Present on today's date |
[data-outside-month] | Present on leading/trailing days |
[data-outside-visible-months] | Present when the date falls outside every rendered month |
[data-disabled] | Present when disabled |
[data-unavailable] | Present when unavailable |
[data-selection-start] | Present on the range's start date |
[data-selection-end] | Present on the range's end date |
[data-range-start] | Present on the visual start, even while the end is unset |
[data-range-end] | Present on the visual end, even while the end is unset |
[data-range-middle] | Present strictly inside a complete range |
[data-highlighted] | Present on the prospective span while the end is being picked |
RangeCalendarDay
The selectable day inside a cell. Sets role="button" with a full date label; renders the day number unless children are passed. Hovering or focusing it drives the range highlight. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-day] | Present |
[data-value] | The cell's date as YYYY-MM-DD |
[data-selected] | Present when selected |
[data-focused] | Present when the placeholder is this date |
[data-today] | Present on today's date |
[data-outside-month] | Present on leading/trailing days |
[data-outside-visible-months] | Present when the date falls outside every rendered month |
[data-disabled] | Present when disabled |
[data-unavailable] | Present when unavailable |
[data-selection-start] | Present on the range's start date |
[data-selection-end] | Present on the range's end date |
[data-range-start] | Present on the visual start, even while the end is unset |
[data-range-end] | Present on the visual end, even while the end is unset |
[data-range-middle] | Present strictly inside a complete range |
[data-highlighted] | Present on the prospective span while the end is being picked |
RangeCalendarHeader
Wraps the heading and the nav buttons. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-header] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarHeading
Shows the visible month(s). Renders the formatted heading unless children are passed. aria-hidden — assistive technology hears the root's label instead. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-heading] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarPrevButton
Pages the view backwards. Disables itself when the previous page falls entirely before minValue. Renders a Button; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-prev-button] | Present |
[data-disabled] | Present when disabled |
RangeCalendarNextButton
Pages the view forwards. Disables itself when the next page falls entirely after maxValue. Renders a Button; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-next-button] | Present |
[data-disabled] | Present when disabled |
RangeCalendarGrid
One month's grid. Sets role="grid" and the aria disabled/readonly state. Renders a Table; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-grid] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarGridHead
Holds the weekday header row. Renders a Thead; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-grid-head] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarGridRow
One row: the weekday names in the head, a week in the body. Renders a Tr; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-grid-row] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarHeadCell
One weekday name. Render the strings from the weekdays render prop into these. Renders a Th; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-head-cell] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarGridBody
Holds the week rows. Renders a Tbody; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-range-calendar-grid-body] | Present |
[data-disabled] | Present when the calendar is disabled |
[data-readonly] | Present when the calendar is readonly |
RangeCalendarMonthSelect
A native select that jumps the view to a month. Renders localized options for every month unless narrowed with the months prop. Renders a Select; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
months | number[] | [1 … 12] | The month numbers to offer. |
monthFormat | Intl.DateTimeFormatOptions["month"] | ((month: number) => string) | — | How option labels are formatted. Defaults to the root's monthFormat. |
disabled | Signal<boolean> | boolean | false | Prevents changing the month. |
| Data attribute | Value |
|---|---|
[data-range-calendar-month-select] | Present |
[data-disabled] | Present when disabled |
RangeCalendarYearSelect
A native select that jumps the view to a year. Offers roughly the last hundred years through the next ten, bounded by minValue/maxValue. Renders a Select; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
years | number[] | — | The years to offer. Defaults to a window around the current year. |
yearFormat | Intl.DateTimeFormatOptions["year"] | ((year: number) => string) | — | How option labels are formatted. Defaults to the root's yearFormat. |
disabled | Signal<boolean> | boolean | false | Prevents changing the year. |
| Data attribute | Value |
|---|---|
[data-range-calendar-year-select] | Present |
[data-disabled] | Present when disabled |