A month grid for choosing a day or a range, in the locale's calendar and digits.
Basic
A Calendar chooses one day. It has no visible label, so name it with aria-label or aria-labelledby. Its value is an ISO date, '2026-09-14', in value or defaultValue and out of onChange. A server component can pass it straight from a URL. Pass today to mark that day with a ring and a bold number, and to open on its month when there is no value.
Example
Example
import { Calendar } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function CalendarBasic(): ReactElement {
return <Calendar aria-label="Due date" defaultValue="2026-09-14" today="2026-09-18" />;
}Range
A RangeCalendar chooses a range in two presses. The first press anchors it, and the days under the pointer or focus preview the range until the second. For a one-day range, press the same day twice. The value is { start, end }, two ISO dates, and onChange fires once the range is complete.
A half-chosen range is dropped when focus leaves the calendar, or when a press lands outside it or on anything in it that is not a button, such as the month heading. An unfinished range never reaches the URL.
presets adds a row of the app's own ranges above the grid. The library computes no ranges: compute "This month" from the app's today, ideally on the server.
- A press sets the range and moves the view to its start.
- A preset shows pressed while the value equals its range.
- A preset is disabled when it falls outside
minValueormaxValue, or holds a dayisDateUnavailablerejects.
Example
Example
import { RangeCalendar, type DateRangePreset } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** The app's presets, computed on the server from its own today, 2026-09-18, with its own words. */
const PRESETS: DateRangePreset[] = [
{ id: 'last-7', label: 'Last 7 days', range: { start: '2026-09-12', end: '2026-09-18' } },
{ id: 'this-month', label: 'This month', range: { start: '2026-09-01', end: '2026-09-30' } },
{ id: 'last-month', label: 'Last month', range: { start: '2026-08-01', end: '2026-08-31' } },
];
export default function CalendarRange(): ReactElement {
return (
<RangeCalendar
aria-label="Statement period"
defaultValue={{ start: '2026-09-12', end: '2026-09-18' }}
today="2026-09-18"
presets={PRESETS}
/>
);
}Unavailable days
minValueandmaxValue: ISO dates. Days outside them are taupe and cannot be chosen.isDateUnavailable: receives each day as an ISO date and returns true for a day that cannot be chosen, such as a closed day or a weekend. Those days are taupe and struck through.
A function cannot cross from a server component, so the example is a client file.
Example
Example
'use client';
import { Calendar } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** Saturdays and Sundays, read from the ISO date the calendar passes. */
const isWeekend = (date: string): boolean => {
const weekday = new Date(`${date}T12:00:00Z`).getUTCDay();
return weekday === 0 || weekday === 6;
};
export default function CalendarUnavailable(): ReactElement {
return (
<Calendar
aria-label="Delivery date"
today="2026-09-18"
minValue="2026-09-03"
maxValue="2026-09-26"
isDateUnavailable={isWeekend}
/>
);
}Calendars and digits
The locale, set through I18nProvider, chooses the calendar and its digits:
fa-IR: the Persian calendar (Shahrivar 1405) in Persian digits, with weeks from Saturday.fa-IR-u-ca-gregory: the Gregorian calendar in Persian digits.fa-IR-u-nu-latn: the Persian calendar in Latin digits.
Values stay Gregorian ISO dates whatever the calendar shown, so a URL means the same day on every page.
Example
Example
import { Calendar, I18nProvider } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** The three locales: the Persian calendar, the Gregorian calendar in Persian digits, and Latin digits. */
const LOCALES = ['fa-IR', 'fa-IR-u-ca-gregory', 'fa-IR-u-nu-latn'];
export default function CalendarPersian(): ReactElement {
return (
<div data-calendar-persian="" dir="rtl" lang="fa" style={{ display: 'flex', flexWrap: 'wrap', gap: 32 }}>
{LOCALES.map((locale) => (
<I18nProvider key={locale} locale={locale}>
<Calendar
aria-label="تاریخ"
defaultValue="2026-09-14"
today="2026-09-18"
labels={{ previous: 'ماه قبل', next: 'ماه بعد' }}
/>
</I18nProvider>
))}
</div>
);
}React Aria's calendar brings every calendar system. To ship only Gregorian and Persian, pass a createCalendar that returns one of the two, from @internationalized/date. Install it at React Aria's own range (^3.12.4) so there is one copy:
'use client';
import { GregorianCalendar, PersianCalendar, type CalendarIdentifier } from '@internationalized/date';
export const createCalendar = (identifier: CalendarIdentifier) =>
identifier === 'persian' ? new PersianCalendar() : new GregorianCalendar();This narrows only a calendar's bundle. Date fields always build their own calendars, so a page with a date range picker ships every system anyway.
Today
Pass today as an ISO date in the app's time zone. Near midnight the device can be on a different day, and a "This month" preset computed on the server would disagree with a mark from the device. Without today, the calendar marks the device's today once the page has loaded. React Aria's spoken "Today" always follows the device.
Usage
Keep a date in a search parameter as its ISO string, ?from=2026-09-01&to=2026-09-30, and pass it as value. A string that does not parse, or a range that ends before it starts, reads as no value and warns in the console. A hand-edited URL never throws.
- A calendar always holds six weeks, so changing months never moves the layout.
- Before hydration, an inline calendar draws its face: the header, weekday row, six weeks of days and a range calendar's presets, with no dates. The face is inert and hidden from screen readers.
- React Aria's calendar mounts in the next render, since its month, weekdays, days and today come from
Intland the device's zone. Nothing moves when it arrives. - A calendar in a popover or a sheet is only drawn in the browser.
Props
Every date is an ISO string.
aria-label string | A name for assistive technology. Give it, or |
|---|---|
className string | A class name, merged after the library's own classes. |
createCalendar ((identifier: CalendarIdentifier) => Calendar) | React Aria's, to ship only the calendar systems the app uses. |
defaultValue string | null | The day chosen at first when |
firstDayOfWeek "fri" | "mon" | "sat" | "sun" | "thu" | "tue" | "wed" | The first day of the week. Default: from the locale |
isDateUnavailable ((date: string) => boolean) | Days that cannot be chosen, such as a closed day. Receives an ISO date. |
isDisabled boolean | Whether no day can be chosen and nothing takes focus. |
isReadOnly boolean | Whether the days can be focused but not chosen. |
labels Partial<CalendarLabels> | The names the calendar writes, in English unless replaced. |
maxValue string | The latest day that can be chosen, as an ISO date. |
minValue string | The earliest day that can be chosen, as an ISO date. |
onChange ((value: string) => void) | Called with the chosen day as a Gregorian ISO date. |
today string | Today in the app's time zone, as an ISO date. Without it, the device's today after hydration. |
value string | null | The chosen day as an ISO date, 'YYYY-MM-DD', or null for none. Gregorian whatever the calendar shown. |
aria-label string | A name for assistive technology. Give it, or |
|---|---|
className string | A class name, merged after the library's own classes. |
createCalendar ((identifier: CalendarIdentifier) => Calendar) | React Aria's, to ship only the calendar systems the app uses. |
defaultValue DateRange | null | The range chosen at first when |
firstDayOfWeek "fri" | "mon" | "sat" | "sun" | "thu" | "tue" | "wed" | The first day of the week. Default: from the locale |
isDateUnavailable ((date: string) => boolean) | Days that cannot be chosen, such as a closed day. Receives an ISO date. |
isDisabled boolean | Whether no day can be chosen and nothing takes focus. |
isReadOnly boolean | Whether the days can be focused but not chosen. |
labels Partial<CalendarLabels> | The names the calendar writes, in English unless replaced. |
maxValue string | The latest day that can be chosen, as an ISO date. |
minValue string | The earliest day that can be chosen, as an ISO date. |
onChange ((value: DateRange) => void) | Called once a range is complete, with its two Gregorian ISO dates. |
presets readonly DateRangePreset[] | A row of preset ranges above the grid. |
today string | Today in the app's time zone, as an ISO date. Without it, the device's today after hydration. |
value DateRange | null | The chosen range as two ISO dates, or null for none. Gregorian whatever the calendar shown. |
id Key | |
|---|---|
label string | The preset's words, the app's: "This month". |
range DateRange | Its range, computed by the app from its own today. |
next string | Default: 'Next month' |
|---|---|
presets string | The name of the row of presets. Default: 'Presets' |
previous string | Default: 'Previous month' |
Accessibility
The calendar is an application named by its aria-label and the visible month, holding a grid of days. Each day is named with its full date and described as today, selected or unavailable, in React Aria's strings, which have no Persian. React Aria announces month changes and the chosen range.
- The month buttons are named "Previous month" and "Next month", or by
labels. - The presets are a group of toggles named "Presets", each pressed while the value equals its range.
- Days are 40px where there is room, and at least 36px in a popover, or in the sheet on a 320px screen.
- Inline, days shrink with their container but never below the 24px target minimum, so the calendar is at least 168px wide.
| LeftRight | The previous or next day, in the drawn direction. |
|---|---|
| UpDown | The same day a week before or after. |
| HomeEnd | The first or last day of the month. |
| PageUpPageDown | The same day a month before or after. |
| Shift+PageUpShift+PageDown | The same day a year before or after. |
| EnterSpace | Chooses the day. In a range, the first press anchors it and moves to the next day, and the second completes it. |
| Escape | Drops a half-chosen range and passes the key on, so a popover closes too. |
| Tab | Moves between the month buttons, the presets and the grid, which is one stop. |
Design rules
What keeps calendars in the system.
Pass today from the time zone of the app.
Compute presets on the server, in the words of the app.
Prefer the sheet for a calendar on a phone, where it has the room.
Never mark a chosen day by color alone. It is filled and selected.
Never put a second month beside the first.
Never show a date the app did not write.