component — date & time

Range Calendar

An accessible date-range calendar built on react-aria-components and @internationalized/date, sharing the Calendar header — the month/year control that swaps the Month Picker into the calendar body by default, chevron steppers via the stepper variant. Rounded cells: the range endpoints fill with the signal colour and the days in-between take the selected tint — foundational for Date Picker and Date Range Picker.

  • calendar
  • date
  • daterange
  • datepicker
  • form
  • input
  • interactive

06 examples

Default

Pick a start and end date; the endpoints fill with the signal colour.

Trip dates, Oktober 2026

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

Preselected range

Set the range with defaultValue — the days in-between take the raised ground.

Stay, Oktober 2026

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

Chevron steppers

The shared header's stepper variant reaches the range calendar too — a chevron on each side of the month and the year.

Trip dates, Oktober 2026

Okt
2026
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

Two months

Show two months side by side with visibleDuration.

Booking range, Oktober bis November 2026

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
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
1
2
3
4
5
6

Minimum date

Days before today are disabled and dimmed via minValue.

Reservation, Oktober 2026

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

Disabled

The whole calendar dims and blocks interaction.

Locked range, Oktober 2026

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

source

range-calendar.tsx

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 as RangeCalendarPrimitiveProps,
} from "react-aria-components"
import {
  CalendarBody,
  CalendarBodyModeProvider,
  CalendarGridHeader,
  CalendarHeader,
  type CalendarHeaderVariant,
} 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. The range endpoints take the signal fill, the days
 * in-between the selected ground, and today is underlined. Composes the shared header, body and grid header
 * from the Calendar component — including its `variant`, so the month/year
 * control swaps the Month Picker into this calendar's body exactly as it does
 * in a single-month one, and the chevron-stepper header is available too.
 * Foundational — Date Picker and Date Range Picker depend on it.
 */

interface RangeCalendarProps<T extends DateValue> extends RangeCalendarPrimitiveProps<T> {
  /** Header treatment — the in-body month picker (default) or chevron steppers. */
  variant?: CalendarHeaderVariant
}

function RangeCalendar<T extends DateValue>({
  className,
  visibleDuration = { months: 1 },
  variant,
  ...props
}: RangeCalendarProps<T>) {
  const now = today(getLocalTimeZone())
  return (
    /* `w-fit` for the reason `Calendar` carries it — see the note there. */
    <RangeCalendarPrimitive
      data-slot="calendar"
      className="w-fit"
      visibleDuration={visibleDuration}
      {...props}
    >
      <CalendarBodyModeProvider>
        <CalendarHeader variant={variant} />
        <CalendarBody>
          <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-quebi-fg outline-hidden",
                          // the whole span sits on the selected ground, its
                          // ends rounded; the endpoints paint signal over it
                          "selected:bg-quebi-selected selected:text-quebi-on-selected",
                          "data-selection-start:rounded-s-(--q-radius-control) data-selection-end:rounded-e-(--q-radius-control)",
                          "data-outside-month:text-quebi-fg-subtle",
                        )}
                      >
                        {({
                          formattedDate,
                          isSelected,
                          isSelectionStart,
                          isSelectionEnd,
                          isDisabled,
                          isUnavailable,
                          isFocusVisible,
                        }) => (
                          <span
                            className={cn(
                              "flex size-full items-center justify-center rounded-(--q-radius-control) tabular-nums transition-colors duration-150",
                              isSelected && (isSelectionStart || isSelectionEnd)
                                ? "bg-quebi-signal text-quebi-on-signal"
                                : isSelected
                                  ? "group-hover/calendar-cell:bg-quebi-signal/15"
                                  : "group-hover/calendar-cell:bg-quebi-raised",
                              // today: an underline, in whichever ink the cell is
                              date.compare(now) === 0 &&
                                "font-medium underline decoration-1 underline-offset-4",
                              isDisabled && "text-quebi-fg-subtle",
                              isUnavailable && "text-quebi-fg-subtle line-through",
                              isFocusVisible && "ring-2 ring-quebi-focus ring-inset",
                            )}
                          >
                            {formattedDate}
                          </span>
                        )}
                      </CalendarCell>
                    )}
                  </CalendarGridBody>
                </CalendarGrid>
              )
            })}
          </div>
        </CalendarBody>
      </CalendarBodyModeProvider>
    </RangeCalendarPrimitive>
  )
}

export type { RangeCalendarProps }
export { RangeCalendar }