Range Calendar
A month grid for picking a start and an end date.
| 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" });
}Installation
npx jsrepo add @implementjs/ui/range-calendar
jsrepo pulls button and calendar 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 and calendar 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 { calendarRootClasses } from "./calendar";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type RangeCalendarProps = RangeCalendarRootProps;
const rangeDayClasses = [
"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",
].join(" ");
/** 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(calendarRootClasses, 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: rangeDayClasses }),
),
),
),
),
),
),
),
),
),
);
});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's border and padding come from calendarRootClasses, exported by calendar.ts — which is why installing this brings the calendar with it. The day classes are its own, since 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. |
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 |