Calendar
An accessible calendar for choosing dates — single, range, or multiple selection, with full keyboard navigation, month/year/decade drill-down, min/max bounds, unavailable dates, native form submission, and deep localization (RTL and non-Gregorian calendar systems included).Import
import { Calendar } from "@hope-ui/components/calendar";Calendar is a compound component — a namespace object whose parts you compose yourself, with a
zero-config convenience mode when you don't. See the Anatomy for every part, and
Convenience & compound for the two ways to render it.
Usage
Calendar.Root owns the state (the selection, the roving focus cursor, month/year/decade view,
date math, ARIA, and — opt-in — a native form value) and renders the role="group" container. Inside
it, Calendar.Header holds the navigation (Calendar.PrevButton, the Calendar.Heading caption, and
Calendar.NextButton), and Calendar.Grid draws the weekday head and the month's day cells. Choose a
day with a click, Enter, or Space.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
const [value, setValue] = createSignal<CalendarValue>(null);
// `defaultFocusedValue` seeds the visible month; a stable date keeps SSR deterministic (see below).
<Calendar.Root
defaultFocusedValue={new CalendarDate(2020, 9, 7)}
value={value()}
onValueChange={setValue}
>
<Calendar.Header>
<Calendar.PrevButton />
<Calendar.Heading />
<Calendar.NextButton />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>;defaultFocusedValue sets which month opens first. In a client-rendered app you can omit it — it
defaults to today. Under SSR or static prerendering, pass a stable date (or a today() computed once on
the server) so the server and client seed the same month: the grid is a variable 4–6 rows, so a
build-time today() landing in a different month than view-time would mismatch on hydration. This page
is prerendered, so every demo pins a fixed reference date.
CalendarDate values from @internationalized/date — a calendar-system-aware date type — so selection, bounds, and formatting stay correct across time zones and calendar systems with no Date parsing on your side.Anatomy
Calendar.Root wraps a Calendar.Header (the navigation row) over a Calendar.Grid (the day grid).
Calendar.Heading is itself a button: clicking it climbs from the month view to a year grid, then a
decade grid — and clicking a year or a month drills back down. Calendar.Grid renders the weekday head
and every day cell internally, so you never assemble rows or cells yourself.
| Part | Element | Description |
|---|---|---|
Calendar.Root | div[role=group] | State owner and the group container. Owns selection, roving focus, the month/year/decade view machine, date math, ARIA, ids, the resolved recipe, and (opt-in) the hidden native form input. |
Calendar.Header | div | The navigation row — lays out the prev/heading/next controls. Layout only. |
Calendar.PrevButton | button | Steps to the previous period (month / year / decade, following the view). Renders the preset's chevron by default; disables at a min bound. |
Calendar.Heading | button | The center caption (e.g. “June 2026”). Click it to zoom out to the year, then decade view. Shows the localized period label unless you pass children. |
Calendar.NextButton | button | Steps to the next period. Renders the preset's chevron by default; disables at a max bound. |
Calendar.Grid | table[role=grid] | The day grid. Renders the weekday header row and the day cells (each a gridcell wrapping a roving day button) for the active view. |
Convenience & compound
Calendar.Root renders two ways. Give it the parts above for full compound control — a custom
heading, extra chrome, a differently-composed layout. Or give it no children at all and it
auto-renders the built-in default chrome (the navigation header with the preset's chevrons, then the
grid). Same recipe, same behavior; the bare form is just less to type.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
// Zero children — Root auto-renders the header + grid.
<Calendar.Root />The rest of this page uses the bare convenience form for brevity — every example works identically as the composed parts.
Selection modes
selectionMode sets how many dates can be chosen:
single(default) — one date; choosing another replaces it. (shown in Usage)range— a start and an end; the days between paint as a band.onValueChangefires only when the range completes, and the value is a{ start, end }object.multiple— an independent set; each click orSpacetoggles a day. The value is aCalendarDate[].
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
<Calendar.Root selectionMode="range" />| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
<Calendar.Root selectionMode="multiple" />Sizes
The size prop scales density — the day-cell box, its text, and the navigation buttons — across sm,
md (the default), and lg. It carries no color axis; the calendar is a neutral date surface, and
selection always paints with the primary / selected tokens.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
<Calendar.Root size="sm" />
<Calendar.Root size="md" />
<Calendar.Root size="lg" />Date boundaries
min and max bound the selectable and reachable range (inclusive). Days outside the window are
disabled and never focusable, and the Calendar.PrevButton / Calendar.NextButton disable at the
edges so navigation can't leave the window.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
<Calendar.Root
min={new CalendarDate(2020, 9, 1)}
max={new CalendarDate(2020, 9, 15)}
/>Unavailable dates
isDateDisabled marks individual dates unavailable — here every weekend. Unavailable days differ
from out-of-range ones: they stay focusable and are announced to screen readers, but render struck
through and can never be selected (React Aria's "unavailable" semantics).
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
<Calendar.Root
isDateDisabled={(date) => {
const weekday = date.toDate("UTC").getUTCDay();
return weekday === 0 || weekday === 6; // Sun / Sat
}}
/>In range mode, unavailable dates also bound the selection: once you pick the first endpoint, the
calendar clamps to the run of available days around it, so a range can never span an unavailable day.
Everything past the nearest one goes inert until the range completes. Pass allowsNonContiguousRanges
to opt out and let a range span them — the unavailable days still drop out of the painted band.
<Calendar.Root
selectionMode="range"
allowsNonContiguousRanges
isDateDisabled={(date) => {
const weekday = date.toDate("UTC").getUTCDay();
return weekday === 0 || weekday === 6; // Sun / Sat
}}
/>Native form submission
Set name and the calendar renders a hidden <input> (a sibling of the group) valued as the selected
date's ISO YYYY-MM-DD string, so a plain <form> submit carries the value with no extra wiring. Add
form to associate the input with a form by id, and required for native validation.
The shape follows the selection mode: single submits one field; multiple submits one field per
selected date (all sharing name); range submits a paired ${name}Start / ${name}End, emitted
only once the range is complete.
<form onSubmit={handleSubmit}>
<Calendar.Root name="date" />
<Button type="submit">Submit</Button>
</form>Localization & i18n
Calendar localizes through @hope-ui/i18n: wrap a subtree in an I18nProvider
and pass a locale, and the heading, weekday names, day numbers, keyboard direction, and screen-reader
announcements all follow — no per-component flags. Everything below changes only the provider's
locale (plus, for RTL, a dir on the layout).
Reading direction (LTR & RTL)
An RTL locale mirrors the whole calendar: the chrome flips (Prev on the right, Next on the left), arrow-key navigation reverses, and the day numbers render in the locale's numbering system — Arabic-Indic digits here, for free.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| السبت | الأحد | الاثنين | الثلاثاء | الأربعاء | الخميس | الجمعة |
|---|---|---|---|---|---|---|
<div dir="rtl">
<I18nProvider locale="ar-EG">
<Calendar.Root />
</I18nProvider>
</div>Set dir on your document root (see i18n) and every calendar follows. The wrapper
above is only needed because this page shows two directions at once.
First day of week
The first column follows the locale by default (en-US → Sunday, fr-FR → Monday). Override it
explicitly with firstDayOfWeek — the weekday header and the day columns shift together.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
<Calendar.Root firstDayOfWeek="sun" />
<Calendar.Root firstDayOfWeek="mon" />Non-Gregorian calendars
Pass a CalendarDate in another calendar system — build it with toCalendar from
@internationalized/date — and pair it with a matching -u-ca- locale. The heading, weekday names,
era, and day numbers then all render in that system: because the day numbers are formatted from the
date's own calendar (not the locale's default), the grid and heading always agree.
| الأحد | الاثنين | الثلاثاء | الأربعاء | الخميس | الجمعة | السبت |
|---|---|---|---|---|---|---|
| 日 | 月 | 火 | 水 | 木 | 金 | 土 |
|---|---|---|---|---|---|---|
| อา. | จ. | อ. | พ. | พฤ. | ศ. | ส. |
|---|---|---|---|---|---|---|
import { CalendarDate, IslamicUmalquraCalendar, toCalendar } from "@internationalized/date";
// Reproject a Gregorian date into the target system, then pair it with a matching `-u-ca-` locale so
// the heading, era, weekday names, and day numbers all render in that system.
const date = toCalendar(new CalendarDate(2020, 9, 7), new IslamicUmalquraCalendar());
<I18nProvider locale="ar-SA-u-ca-islamic-umalqura">
<Calendar.Root defaultFocusedValue={date} />
</I18nProvider>;Locale-driven formatting
With no other config, the same month renders its heading, weekday names, and day numbers in the active
locale — French (Monday-first) and Japanese (Sunday-first, 2026年6月) below.
| lun. | mar. | mer. | jeu. | ven. | sam. | dim. |
|---|---|---|---|---|---|---|
| 日 | 月 | 火 | 水 | 木 | 金 | 土 |
|---|---|---|---|---|---|---|
<I18nProvider locale="fr-FR">
<Calendar.Root />
</I18nProvider>
<I18nProvider locale="ja-JP">
<Calendar.Root />
</I18nProvider>Keyboard interactions
Calendar implements the WAI-ARIA grid keyboard pattern for a date picker. Arrow directions respect the reading direction (they reverse under RTL).
| Key | Action |
|---|---|
← / → | Move focus to the previous / next day (reversed under RTL). |
↑ / ↓ | Move focus to the same weekday in the previous / next week. |
Home / End | Move to the first / last day of the week. |
PageUp / PageDown | Move to the previous / next month. |
Shift + PageUp / PageDown | Move to the same month one year back / forward. |
Shift + ← / → / ↑ / ↓ | In range mode, extend the range by a day (or a week) from the focused day. Unavailable days stop the extension, unless allowsNonContiguousRanges lets it step over them. |
Enter / Space | Select the focused day (or, in year/decade view, drill into it). |
Escape | In range mode, cancel a range in progress — the previously selected range comes back. Passes through untouched when there is nothing to cancel, so an enclosing popover still closes on it. |
Theming
Calendar's look comes from the active preset's recipe. It is a neutral date surface — no color
axis; the only accents are the selection (the primary tokens) and the range / hover band (the
selected tokens), all token-driven. Override styling at three levels, applied in order (later wins a
Tailwind conflict): recipe base → preset slotClasses → instance slotClasses / part class.
Parts
Every part carries a data-slot attribute you can target, and each is addressable by name through
slotClasses. The day-cell state (today, outside-month, selected, range endpoints/middle, tentative
highlight, disabled) is painted on cellTrigger through the preset's registered data-* variants, so
pointer and keyboard share one visual state.
| Slot | data-slot | Description |
|---|---|---|
root | calendar | The role=group container. |
header | calendar-header | The navigation row. |
prevButton | calendar-prev-button | The previous-period button. |
heading | calendar-heading | The center caption button (drills up to year/decade). |
nextButton | calendar-next-button | The next-period button. |
grid | calendar-grid | The table[role=grid] day grid. |
weekday | calendar-weekday | A weekday header cell (th). |
cell | calendar-cell | A day cell (td[role=gridcell]). |
cellTrigger | calendar-cell-trigger | The roving day button — carries all the day-state data-* attributes. |
Overriding one calendar
Set slotClasses on Calendar.Root to reach any slot from one place, or put class on an individual
part. class on Calendar.Root merges onto the root slot. Use literal class strings so your
Tailwind build can see them.
<Calendar.Root
class="rounded-xl border border-subtle bg-surface-raised p-3 shadow-sm"
slotClasses={{ cellTrigger: "rounded-full", heading: "font-semibold" }}
/>App-wide defaults & overrides
Set the default size, the navigation glyphs (prevIcon / nextIcon), and global part classes for
every Calendar through the theme with definePreset, then pass the derived preset to
ThemeProvider. defaultProps resolve at instance ?? preset ?? builtin precedence, so an explicit
prop on a single calendar always wins.
import { definePreset } from "@hope-ui/theming";
import { hope } from "@hope-ui/presets/hope";
export const myPreset = definePreset(hope, {
components: {
calendar: {
defaultProps: {
size: "sm",
prevIcon: () => <ArrowLeftIcon />,
nextIcon: () => <ArrowRightIcon />,
},
slotClasses: { root: "rounded-xl border border-subtle p-3 shadow-sm" },
},
},
});API
Calendar.Root
Accepts the props below plus every native <div> attribute (id, style, data-*, ref, event
handlers, …), which land on the role="group" container. Dates are CalendarDate values from
@internationalized/date.
| Prop | Default | Type |
|---|---|---|
selectionMode | "single" | "single" | "range" | "multiple"How many dates can be selected, and the shape of value. |
value | — | CalendarDate | DateRange | CalendarDate[] | nullControlled selection, keyed by selectionMode. Omit for uncontrolled use via defaultValue. |
defaultValue | null | CalendarDate | DateRange | CalendarDate[] | nullInitial selection when uncontrolled. |
onValueChange | — | (value) => voidCalled when the selection commits (range: only on completion). |
defaultFocusedValue | value / today | CalendarDate | nullInitial focus cursor (uncontrolled) — seeds the visible month. Pass a stable value for deterministic SSR. |
focusedValue | — | CalendarDate | nullControlled roving-focus cursor. |
onFocusedValueChange | — | (date: CalendarDate) => voidCalled whenever the roving cursor moves. |
min | — | CalendarDateEarliest selectable / reachable date (inclusive). |
max | — | CalendarDateLatest selectable / reachable date (inclusive). |
isDateDisabled | — | (date: CalendarDate) => booleanPer-date unavailable predicate — focusable and announced, but not selectable. |
allowsNonContiguousRanges | false | booleanRange mode: let a selection span unavailable days. By default a range in progress clamps at the nearest unavailable day. |
commitBehavior | "select" | "select" | "reset" | "clear"Range mode: what happens to a range abandoned mid-selection — the pointer released outside the calendar, or focus leaving it. select completes it at the focused day, reset restores the previous range, clear empties the selection. Escape always cancels, whatever this is set to. |
firstDayOfWeek | locale-derived | "sun" | "mon" | … | "sat"Week-start override. Defaults to the locale's convention. |
locale | useLocale() | stringBCP-47 locale for date formatting. Defaults to the nearest I18nProvider / browser locale. |
dir | useLocale() | "ltr" | "rtl"Mirrors this calendar on its own, overriding the page's direction. Unset, it follows the page. |
timeZone | system zone | stringIANA time zone for "today" and formatting. |
disabled | false | booleanDisable the whole calendar. |
readOnly | false | booleanNavigable and focusable, but not selectable. |
size | "md" | "sm" | "md" | "lg"The density scale — day-cell box, text, and navigation buttons. |
prevIcon | chevron | () => JSX.ElementFactory for the PrevButton's default glyph. Overridable app-wide via the preset. |
nextIcon | chevron | () => JSX.ElementFactory for the NextButton's default glyph. Overridable app-wide via the preset. |
name | — | stringNative form field name. When set, hidden input(s) render from the selection. |
form | — | stringAssociates the hidden field(s) with a form by id. |
required | false | booleanMarks the field required for native validation. |
label | localized | stringThe group's accessible name. Defaults to the localized calendar.label message. |
slotClasses | — | SlotClasses<"calendar">Per-slot class overrides, keyed by root/header/heading/prevButton/nextButton/grid/weekday/cell/cellTrigger. |
render | — | (props) => JSX.ElementRender the group container as another element or component. Receives Root's computed props; honour the ref it passes. |
class | — | stringMerged onto the root slot (applied last). |
children | auto chrome | JSX.ElementThe compound parts. Omit for the built-in default chrome (see Convenience & compound). |
Parts
Calendar.PrevButton, Calendar.NextButton, and Calendar.Heading each accept their native
<button> attributes, a render prop, a class, and children (which overrides the built-in
glyph / period label). Calendar.Header and Calendar.Grid accept their native element attributes
plus a render prop and a class. Every part that renders a host element takes render — the
single polymorphism API, as elsewhere in hope-ui.
Calendar.Grid's children are <thead>/<tbody>/<tr>, so render-ing it as something that isn't
table-shaped produces invalid HTML. The computed props (role="grid", the aria-labelledby, the
keymap) come with you either way.
Accessibility
Calendar implements the WAI-ARIA grid date-picker pattern.
- Roles and state are wired for you. The container is
role="group"with an accessible name, the grid isrole="grid"labelled by the heading, each day is arole="gridcell"button, and selected / today / disabled / unavailable state is exposed througharia-*anddata-*. - One roving tab stop. The grid is a single tab stop; arrows move focus between days, and pointer and keyboard share one focused day, so the cursor and the keyboard never paint two highlights.
- Unavailable days stay discoverable. A date marked unavailable keeps focus and is announced, but can never be selected — distinct from an out-of-range day, which is fully disabled.
- Selections are announced. Committing a date or completing a range announces the localized result through a live region; navigating months / views announces the new period.
- Localized and directional. The accessible names, weekday labels, and day numbers come from the active locale, and arrow-key navigation follows the reading direction.