hope-uiearly-preview

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.

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

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.

Calendar.Root
├── Calendar.Header
│ ├── Calendar.PrevButton
│ ├── Calendar.Heading
│ └── Calendar.NextButton
└── Calendar.Grid weekday head + day cells, internally
PartElementDescription
Calendar.Rootdiv[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.HeaderdivThe navigation row — lays out the prev/heading/next controls. Layout only.
Calendar.PrevButtonbuttonSteps to the previous period (month / year / decade, following the view). Renders the preset's chevron by default; disables at a min bound.
Calendar.HeadingbuttonThe 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.NextButtonbuttonSteps to the next period. Renders the preset's chevron by default; disables at a max bound.
Calendar.Gridtable[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.

// 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. onValueChange fires only when the range completes, and the value is a { start, end } object.
  • multiple — an independent set; each click or Space toggles a day. The value is a CalendarDate[].
Range:
range
<Calendar.Root selectionMode="range" />
Selected:
multiple
<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.

sm
md
lg
<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.

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

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

Not submitted yet
<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.

en-US (LTR)
ar-EG (RTL)
<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.

firstDayOfWeek="sun"
firstDayOfWeek="mon"
<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.

Islamic (Umm al-Qura)
Japanese
Buddhist
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.

Français (fr-FR)
日本語 (ja-JP)
<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).

KeyAction
← / →Move focus to the previous / next day (reversed under RTL).
↑ / ↓Move focus to the same weekday in the previous / next week.
Home / EndMove to the first / last day of the week.
PageUp / PageDownMove to the previous / next month.
Shift + PageUp / PageDownMove 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 / SpaceSelect the focused day (or, in year/decade view, drill into it).
EscapeIn 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.

Slotdata-slotDescription
rootcalendarThe role=group container.
headercalendar-headerThe navigation row.
prevButtoncalendar-prev-buttonThe previous-period button.
headingcalendar-headingThe center caption button (drills up to year/decade).
nextButtoncalendar-next-buttonThe next-period button.
gridcalendar-gridThe table[role=grid] day grid.
weekdaycalendar-weekdayA weekday header cell (th).
cellcalendar-cellA day cell (td[role=gridcell]).
cellTriggercalendar-cell-triggerThe 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.

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

PropDefaultType
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.
defaultValuenull
CalendarDate | DateRange | CalendarDate[] | nullInitial selection when uncontrolled.
onValueChange
(value) => voidCalled when the selection commits (range: only on completion).
defaultFocusedValuevalue / 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.
allowsNonContiguousRangesfalse
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.
firstDayOfWeeklocale-derived
"sun" | "mon" | … | "sat"Week-start override. Defaults to the locale's convention.
localeuseLocale()
stringBCP-47 locale for date formatting. Defaults to the nearest I18nProvider / browser locale.
diruseLocale()
"ltr" | "rtl"Mirrors this calendar on its own, overriding the page's direction. Unset, it follows the page.
timeZonesystem zone
stringIANA time zone for "today" and formatting.
disabledfalse
booleanDisable the whole calendar.
readOnlyfalse
booleanNavigable and focusable, but not selectable.
size"md"
"sm" | "md" | "lg"The density scale — day-cell box, text, and navigation buttons.
prevIconchevron
() => JSX.ElementFactory for the PrevButton's default glyph. Overridable app-wide via the preset.
nextIconchevron
() => 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.
requiredfalse
booleanMarks the field required for native validation.
labellocalized
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).
childrenauto 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 is role="grid" labelled by the heading, each day is a role="gridcell" button, and selected / today / disabled / unavailable state is exposed through aria-* and data-*.
  • 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.