Range Calendar
A month grid for picking a start and an end date.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
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 | 1 | 2 | 3 |
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" });
}Installation
npx jsrepo add @implementjs/ui/range-calendar
jsrepo pulls button along with it, and installs @implementjs/lucide.
Copy the file below to src/lib/components/ui/range-calendar.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Div, ForEach, Fragment } from "@implementjs/core";
import { ChevronLeftIcon, ChevronRightIcon } from "@implementjs/lucide";
import {
RangeCalendar as RangeCalendarPrimitive,
RangeCalendarCell,
RangeCalendarDay,
RangeCalendarGrid,
RangeCalendarGridBody,
RangeCalendarGridHead,
RangeCalendarGridRow,
RangeCalendarHeadCell,
RangeCalendarHeader,
RangeCalendarHeading,
RangeCalendarNextButton,
RangeCalendarPrevButton,
type RangeCalendarRootProps,
} from "@implementjs/primitives";
import { buttonVariants } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type RangeCalendarProps = RangeCalendarRootProps;
/** A fully assembled, styled range calendar. Forward any root prop from the primitive. */
export const RangeCalendar = createComponent(function RangeCalendar({
class: className,
...props
}: RangeCalendarProps) {
return RangeCalendarPrimitive(
{
...props,
class: cn("inline-block rounded-md border bg-background p-3 shadow-sm", className),
},
({ months, weekdays }) =>
Fragment(
RangeCalendarHeader(
{ class: "flex items-center justify-between" },
RangeCalendarPrevButton(
{ class: buttonVariants({ variant: "ghost", size: "icon-sm" }) },
ChevronLeftIcon({ "aria-hidden": true, class: "size-4" }),
),
RangeCalendarHeading({ class: "text-sm font-medium" }),
RangeCalendarNextButton(
{ class: buttonVariants({ variant: "ghost", size: "icon-sm" }) },
ChevronRightIcon({ "aria-hidden": true, class: "size-4" }),
),
),
Div(
{ class: "mt-3 flex flex-col gap-4 md:flex-row" },
ForEach(
months,
(month) => month.value.toString(),
(month) =>
RangeCalendarGrid(
{ class: "w-full border-collapse" },
RangeCalendarGridHead(
{},
RangeCalendarGridRow(
{ class: "flex" },
ForEach(
weekdays,
(_, i) => i,
(weekday) =>
RangeCalendarHeadCell(
{
class: "w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground",
},
weekday,
),
),
),
),
RangeCalendarGridBody(
{},
ForEach(
month.bind((m) => m.weeks),
(week) => week[0]!.toString(),
(week) =>
RangeCalendarGridRow(
{ class: "mt-1 flex w-full" },
ForEach(
week,
(date) => date.toString(),
(date) =>
RangeCalendarCell(
{ date, month, class: "p-0" },
RangeCalendarDay({
class: cn(
"inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-sm font-normal whitespace-nowrap select-none",
"outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50",
"data-today:not-data-selected:not-data-highlighted:bg-accent data-today:not-data-selected:not-data-highlighted:text-accent-foreground",
"data-highlighted:not-data-selection-start:not-data-selection-end:rounded-none data-highlighted:not-data-selection-start:not-data-selection-end:bg-accent data-highlighted:not-data-selection-start:not-data-selection-end:text-accent-foreground",
"data-range-middle:rounded-none data-range-middle:bg-accent data-range-middle:text-accent-foreground",
"data-selection-start:rounded-md data-selection-start:bg-primary data-selection-start:text-primary-foreground data-selection-start:hover:bg-primary",
"data-selection-end:rounded-md data-selection-end:bg-primary data-selection-end:text-primary-foreground data-selection-end:hover:bg-primary",
"data-outside-month:text-muted-foreground",
"data-disabled:pointer-events-none data-disabled:text-muted-foreground data-disabled:opacity-50",
"data-unavailable:text-muted-foreground data-unavailable:line-through",
),
}),
),
),
),
),
),
),
),
),
),
);
});Usage
Assembled like the calendar — navigation, weekday header, and grid built in — with day styling for the shape of a range: rounded ends, a squared-off band through the middle, and a live highlight over the span being picked before the second click lands.
value is a DateRange: { start, end }, either of which can be null while a selection is in progress.
import { signal } from "@implementjs/core";
import { today, type DateRange } from "@implementjs/primitives";
import { RangeCalendar } from "@/lib/components/ui/range-calendar";
const value = signal<DateRange>({ start: today(), end: today().add({ days: 4 }) });
RangeCalendar({ value, calendarLabel: "Trip dates" });
Two months at a time
numberOfMonths: 2 is the usual shape for a date range picker; the styled root already lays the months out in a row on wider screens and stacks them below md.
RangeCalendar({ value, numberOfMonths: 2, calendarLabel: "Trip dates" });
What it shares with the calendar
The root wears the same border and padding as the calendar, written out in this file rather than borrowed from it, so the range calendar installs on its own. The day classes differ either way: a range needs states a single date has no use for.
The root is assembled, so the header, grid, and cell parts are not re-exported here. Import them from @implementjs/primitives when you want a different structure; Range Calendar covers them.
API Reference
Every prop the styling does not consume is forwarded to the Range Calendar primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
RangeCalendar
A range calendar, fully assembled: month navigation, the weekday header, and the day grid are built in, with day styling for the start, middle, and end of a range. Every root prop below is forwarded. 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. |
onValueChange | (value: DateRange) => void | — | Runs whenever the range changes, including while only one end is picked. |
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 | Readable<boolean> | boolean | false | Disables the whole calendar. |
readonly | Readable<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 |