Forms
Range Calendar
An accessible date-range calendar built on react-aria-components and @internationalized/date. The range endpoints fill with brand teal and the days in-between get a faint brand wash — foundational for Date Picker and Date Range Picker.
calendardatedaterangedatepickerforminputinteractive
Default
Pick a start and end date; the endpoints fill with brand teal.
June 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
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 | 4 |
Preselected range
Set the range with defaultValue — the days in-between get a faint brand wash.
June 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
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 | 4 |
Two months
Show two months side by side with visibleDuration.
June – July 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
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 | 4 |
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
28 | 29 | 30 | 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 |
Minimum date
Days before today are disabled and dimmed via minValue.
June 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
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 | 4 |
Disabled
The whole calendar dims and blocks interaction.
June 2026
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
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 | 4 |
Source
Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { getLocalTimeZone, today } from "@internationalized/date"
import {
CalendarCell,
CalendarGrid,
CalendarGridBody,
type DateValue,
RangeCalendar as RangeCalendarPrimitive,
type RangeCalendarProps,
} from "react-aria-components"
import { CalendarGridHeader, CalendarHeader } from "@/components/calendar"
import { cn } from "@/lib/utils"
/**
* Range Calendar — quebi design system
*
* An accessible date-range calendar built on react-aria-components and
* @internationalized/date. Restyled to quebi tokens: the range endpoints fill
* with brand teal, the days in-between get a faint brand wash, and today is
* marked with a brand dot. Composes the shared header and grid header from the
* Calendar component. Foundational — Date Picker and Date Range Picker depend
* on it.
*/
function RangeCalendar<T extends DateValue>({
className,
visibleDuration = { months: 1 },
...props
}: RangeCalendarProps<T>) {
const now = today(getLocalTimeZone())
return (
<RangeCalendarPrimitive data-slot="calendar" visibleDuration={visibleDuration} {...props}>
<CalendarHeader isRange />
<div className="flex snap-x items-start justify-stretch gap-6 overflow-auto sm:gap-10">
{Array.from({ length: visibleDuration?.months ?? 1 }).map((_, index) => {
const id = index + 1
return (
<CalendarGrid
// biome-ignore lint/suspicious/noArrayIndexKey: stable array derived from visibleDuration
key={index}
offset={id >= 2 ? { months: id - 1 } : undefined}
className="[&_td]:border-collapse [&_td]:px-0 [&_td]:py-0.5"
>
<CalendarGridHeader />
<CalendarGridBody className="snap-start">
{(date) => (
<CalendarCell
date={date}
className={cn(
"group/calendar-cell relative size-9 shrink-0 cursor-default text-sm text-white outline-hidden",
// in-between (selected, not an endpoint) days get a faint brand wash
"selected:bg-quebi-brand/15",
// round the range ends
"selection-start:rounded-s-quebi-sm data-selection-end:rounded-e-quebi-sm",
"data-outside-month:text-quebi-fg-subtle",
)}
>
{({ formattedDate, isSelected, isSelectionStart, isSelectionEnd, isDisabled }) => (
<span
className={cn(
"flex size-full items-center justify-center rounded-quebi-sm tabular-nums transition-colors",
isSelected && (isSelectionStart || isSelectionEnd)
? // endpoints: solid brand teal on quebi background
"bg-quebi-brand text-quebi-bg hover:bg-quebi-brand-hover"
: isSelected
? // in-between days: faint brand wash, darker on hover
"group-hover/calendar-cell:bg-quebi-brand/25"
: // unselected days: faint white wash on hover
"group-hover/calendar-cell:bg-white/[0.04]",
// today marker dot
date.compare(now) === 0 &&
!(isSelected && (isSelectionStart || isSelectionEnd)) &&
"relative after:pointer-events-none after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-quebi-brand",
isDisabled && "text-quebi-fg-subtle",
)}
>
{formattedDate}
</span>
)}
</CalendarCell>
)}
</CalendarGridBody>
</CalendarGrid>
)
})}
</div>
</RangeCalendarPrimitive>
)
}
export { RangeCalendar }