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.

Trip dates, June 2026

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.

Stay, June 2026

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.

Booking range, June to July 2026

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
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.

Reservation, June 2026

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.

Locked range, June 2026

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 }