An inline month grid for picking a single date, several dates or a date range. Supports min/max bounds, unavailable dates, multiple visible months, any locale and calendar system, and full keyboard navigation.
Values are CalendarDate (or CalendarDateTime / ZonedDateTime) objects
from @internationalized/date,
not JavaScript Dates. Create them with today(getLocalTimeZone()) or
parseDate("2027-03-15"), compare them with a.compare(b), and convert with
date.toDate(timeZone) when you need a Date. The calendar follows the
user's locale, calendar system and first day of the week automatically.
<Calendar> {/* or <RangeCalendar> */} <header> <Button slot="previous" /> <Heading /> {/* "March 2027"; visually hidden with dropdowns */} {/* captionLayout="dropdown": month <Select> + year <Select> */} <Button slot="next" /> </header> <CalendarGrid> {/* one per visible month */} <CalendarGridHeader> {/* weekday names */} <CalendarGridBody> <CalendarCell /> {/* one per day */} </CalendarGridBody> </CalendarGrid> <Text slot="errorMessage" /> {/* when errorMessage is set */}</Calendar>
Part
Renders
Notes
Calendar
<div>
Single date, or several dates with selectionMode="multiple". data-slot="calendar".
RangeCalendar
<div>
A contiguous start/end range. data-slot="range-calendar".
Previous / next
<button>
Ghost icon Buttons. Disabled automatically at minValue / maxValue.
Heading
<h2 aria-hidden>
The visible month (or range of months), formatted for the locale. Screen readers get the month from the grid label instead.
Caption selects
Select › <button> + listbox popover
With captionLayout set to a dropdown layout. Labelled with the localized words for month and year. data-slot="calendar-caption" on the wrapper.
Grid
<table role="grid">
Rendered once per month in visibleDuration. Weekdays use the short style.
Cell
<td> › <div role="button">
Each date. All states are exposed as data attributes.
Error message
<span slot="errorMessage">
Shown below the grid whenever errorMessage is set.
The header, grid and cells are built in, so the only thing you pass is props. For a custom layout, compose React Aria's Calendar parts directly and reuse the cell styles from the source.
minValue and maxValue disable dates outside the bounds. Disabled dates can't be focused, and the previous and next buttons disable themselves at the edges.
Pickup date, October 2026
October 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
27
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
Pickup is available for the next three weeks.
import { getLocalTimeZone, today } from "@internationalized/date";import { Calendar } from "@/components/ui/calendar";export default function CalendarMinMax() { const now = today(getLocalTimeZone()); return ( <div className="flex flex-col items-center gap-2"> <Calendar aria-label="Pickup date" minValue={now} maxValue={now.add({ days: 21 })} className="rounded-lg border bg-card shadow-xs" /> <p className="text-muted-foreground text-xs"> Pickup is available for the next three weeks. </p> </div> );}
captionLayout="dropdown" replaces the heading with a month select and a year select, so users can jump years at a time. That's the right choice for dates far from today, such as a birth date. The arrows still page one month at a time.
The year list runs from fromYear to toYear. Without them it uses the years of minValue and maxValue, then falls back to 100 years ago through 10 years ahead. Months outside minValue / maxValue are disabled in the month list.
Date of birth, June 1990
June 1990
Sun
Mon
Tue
Wed
Thu
Fri
Sat
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
import { getLocalTimeZone, today } from "@internationalized/date";import { Calendar } from "@/components/ui/calendar";export default function CalendarCaptionDropdown() { const now = today(getLocalTimeZone()); return ( <Calendar aria-label="Date of birth" captionLayout="dropdown" fromYear={1940} maxValue={now} defaultFocusedValue={now.set({ year: 1990, month: 6, day: 15 })} className="rounded-lg border bg-card shadow-xs" /> );}
captionLayout takes four values: label (the default heading), dropdown (both selects), dropdown-months (a month select and the year as text) and dropdown-years (the month as text and a year select). Month names and years follow the locale and calendar system.
Delivery date, October 2026
October 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
27
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
import { getLocalTimeZone, today } from "@internationalized/date";import { useState } from "react";import { Calendar, type CalendarCaptionLayout } from "@/components/ui/calendar";import { ToggleButton } from "@/components/ui/toggle-button";import { ToggleButtonGroup } from "@/components/ui/toggle-button-group";const layouts: CalendarCaptionLayout[] = [ "label", "dropdown", "dropdown-months", "dropdown-years",];export default function CalendarCaptionLayouts() { const [layout, setLayout] = useState<CalendarCaptionLayout>("dropdown"); return ( <div className="flex flex-col items-center gap-4"> <ToggleButtonGroup aria-label="Caption layout" selectionMode="single" disallowEmptySelection selectedKeys={[layout]} onSelectionChange={(keys) => setLayout([...keys][0] as CalendarCaptionLayout) } variant="segmented" size="sm" > {layouts.map((l) => ( <ToggleButton key={l} id={l}> {l} </ToggleButton> ))} </ToggleButtonGroup> <Calendar aria-label="Delivery date" captionLayout={layout} defaultValue={today(getLocalTimeZone())} className="rounded-lg border bg-card shadow-xs" /> </div> );}
RangeCalendar takes the same props. With several visible months, the selects follow the focused month, and picking a month scrolls it into view. Here the window is limited to 11 months, so dropdown-months is enough.
Stay dates, October to November 2026
October – November 2026
2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
27
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
Sun
Mon
Tue
Wed
Thu
Fri
Sat
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
Bookings open 11 months ahead. Jump straight to a month from the header.
import { getLocalTimeZone, today } from "@internationalized/date";import { RangeCalendar } from "@/components/ui/calendar";export default function CalendarCaptionRange() { const now = today(getLocalTimeZone()); return ( <div className="flex flex-col items-center gap-2"> <RangeCalendar aria-label="Stay dates" captionLayout="dropdown-months" minValue={now} maxValue={now.add({ months: 11 })} visibleDuration={{ months: 2 }} pageBehavior="single" className="rounded-lg border bg-card shadow-xs" /> <p className="text-muted-foreground text-xs"> Bookings open 11 months ahead. Jump straight to a month from the header. </p> </div> );}
isDateUnavailable marks individual dates (holidays, booked days, weekends) as unavailable. Unlike disabled dates, they stay focusable so keyboard and screen reader users can still reach them, and they're struck through.
By default a range can't include an unavailable date. Set allowsNonContiguousRanges to let the range span them, for example a leave request across a weekend. For RangeCalendar, isDateUnavailable also receives the anchor date (the first date clicked), so you can make availability depend on the start date.
Leave request, October 2026
October 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
27
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
import { type DateValue, getLocalTimeZone, isWeekend, today,} from "@internationalized/date";import { useState } from "react";import { useLocale } from "react-aria-components";import { RangeCalendar } from "@/components/ui/calendar";import { Switch } from "@/components/ui/switch";export default function CalendarRangeUnavailable() { const { locale } = useLocale(); const [allowWeekends, setAllowWeekends] = useState(false); return ( <div className="flex flex-col items-center gap-3"> <RangeCalendar aria-label="Leave request" minValue={today(getLocalTimeZone())} isDateUnavailable={(date: DateValue) => isWeekend(date, locale)} allowsNonContiguousRanges={allowWeekends} className="rounded-lg border bg-card shadow-xs" /> <Switch isSelected={allowWeekends} onChange={setAllowWeekends} size="sm"> Span across weekends </Switch> </div> );}
visibleDuration={{ months: 2 }} shows consecutive months side by side (stacked on narrow screens). pageBehavior="single" pages one month at a time instead of a whole visible page.
Wrap the calendar in I18nProvider to set a locale. Month and weekday names, digits, text direction and the calendar system all follow it. Add a -u-ca- extension for non-Gregorian calendars (ja-JP-u-ca-japanese, th-TH-u-ca-buddhist). The value you receive is still a CalendarDate in that calendar; convert with toCalendar(date, new GregorianCalendar()) before storing it.
focusedValue and onFocusChange control which date has focus, and therefore which month is visible. Use them for "Today" or "Jump to" buttons. defaultFocusedValue sets only the initial month.
Set isInvalid from your own logic and pass errorMessage to explain it. The selected date turns red, and the message is shown below the grid and linked to it for screen readers.
Deployment date, October 2026
October 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
27
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
Deploys are frozen on weekends. Pick a weekday.
import { type CalendarDate, getLocalTimeZone, isWeekend, today,} from "@internationalized/date";import { useState } from "react";import { useLocale } from "react-aria-components";import { Calendar } from "@/components/ui/calendar";export default function CalendarValidation() { const { locale } = useLocale(); const [date, setDate] = useState<CalendarDate | null>(() => { // Start on the next Saturday so the error is visible. let d = today(getLocalTimeZone()); while (!isWeekend(d, locale)) d = d.add({ days: 1 }); return d; }); const isInvalid = date !== null && isWeekend(date, locale); return ( <Calendar aria-label="Deployment date" value={date} onChange={setDate} isInvalid={isInvalid} errorMessage={ isInvalid ? "Deploys are frozen on weekends. Pick a weekday." : undefined } className="max-w-72 rounded-lg border bg-card shadow-xs" /> );}
Moves between the previous button, next button and the grid
← / →
Previous / next day (reversed in RTL locales)
↑ / ↓
Same day in the previous / next week
Home / End
First / last day of the month
Page Up / Page Down
Previous / next month
Shift+Page Up / Shift+Page Down
Previous / next year
Enter / Space
Selects the focused date (in a range: sets the start, then the end)
Esc
Cancels a range selection in progress
With a dropdown captionLayout, the month and year selects sit between the previous and next buttons in the tab order. Enter, Space or ↓ opens a select, typing jumps to a matching month or year, and the grid moves to the chosen month.