nori-ui

Calendar

Inline visual calendar for picking dates, ranges, or multiple selections — with locale-aware first day of week, weekend marking, drill-down navigation, and slot-composable headers.

15.3 kBgzipped

The <Calendar> component is an inline date picker. It supports single, range, and multi-select modes; click the title to drill down through month and year views; and respects the active locale's first day of week, weekend days, and weekday names.

Basic usage

Direction:
Locale:
import { useState } from 'react';
import { Calendar } from '@nori-ui/core';
import type { CalendarDate } from '@internationalized/date';
 
const [value, setValue] = useState<CalendarDate | null>(null);
 
<Calendar value={value} onChange={(v) => setValue(v)} />

Selection modes

Calendar's selection shape is controlled by the mode prop. The value / defaultValue / onChange types are inferred from mode:

modevalue type
'single' (default)CalendarDate | null
'range'{ start: CalendarDate; end: CalendarDate | null } | null
'multiple'CalendarDate[]

Range selection

Two-month layout on desktop (visibleMonths={2}), single-month on mobile by default.

Direction:
Locale:

Use minNights / maxNights to constrain the gap between start and end — useful for hotel bookings (minimum stay) or short-term rental floors / caps.

<Calendar mode="range" minNights={2} maxNights={14} />

Multiple selection

Direction:
Locale:

Visible months

visibleMonths controls how many month grids render side-by-side. Pass an explicit number, or 'auto' (default for range mode) to render two on viewports ≥ 768px and one on smaller screens.

<Calendar visibleMonths={3} />          // always three months
<Calendar mode="range" visibleMonths="auto" /> // 2 on desktop, 1 on mobile

Drill-down navigation

Click the title to toggle between day, month, and year views. defaultView="year" opens directly into the decade picker — handy for birthday inputs.

Direction:
Locale:

The view is controllable via view + onViewChange:

const [view, setView] = useState<CalendarView>('day');
<Calendar view={view} onViewChange={setView} />

Header layout: caption

The header has three layouts:

  • caption="title" (default) — centered title, click to drill down.
  • caption="dropdown"[ May ▾ ] [ 2026 ▾ ] Select pills.
  • caption="custom" — render your own header via <Calendar.Caption>.

Replaces the title-click drilldown with two Select pills — one for the month, one for the year. Useful when the user is likely to jump across years (birthdays, far-future bookings).

Direction:
Locale:
<Calendar caption="dropdown" yearRange={[2020, 2030]} />

yearRange defaults derive from minValue / maxValue if set, else [focused.year - 100, focused.year + 10] — covers birthday-pickers and short-term-booking ranges.

Custom captions (slot composition)

Switch to caption="custom" to drop the built-in caption entirely and render your own dropdowns or nav. Wrap them in <Calendar.Caption> to make the slot intent explicit:

<Calendar caption="custom">
    <Calendar.Caption>
        <MyMonthSelect />
        <MyYearSelect />
    </Calendar.Caption>
</Calendar>

Inside any descendant, call useCalendarCaption() for state + setters:

import { useCalendarCaption } from '@nori-ui/core';
 
const MyMonthSelect = () => {
    const { month, monthOptions, setMonth } = useCalendarCaption();
    return <YourSelect value={month} onChange={setMonth} options={monthOptions} />;
};

The full hook return shape:

FieldTypeNotes
monthnumber1..12, currently visible month
yearnumberCurrently visible year
visibleMonthCalendarDateFirst day of the visible month
monthOptionsReadonlyArray<CaptionOption>All months; disabled set if outside min/max
yearOptionsReadonlyArray<CaptionOption>Years inside yearRange
setMonth(month: number) => voidSnap to a specific month (1..12)
setYear(year: number) => voidSnap to a specific year
goPrev() => voidPage back (month/year/decade per current view)
goNext() => voidPage forward (month/year/decade per current view)

Constraining selection

Use minValue / maxValue to bound the selectable range, and isDateUnavailable for arbitrary disables (weekends, holidays, server-fetched blackout periods). Disabled cells render dimmed and cannot be focused or selected.

Min / max

Direction:
Locale:
import { Calendar } from '@nori-ui/core';
import { today, getLocalTimeZone } from '@internationalized/date';
 
const start = today(getLocalTimeZone());
const end = start.add({ days: 30 });
 
<Calendar minValue={start} maxValue={end} />

Disabled dates

isDateUnavailable is a synchronous predicate the Calendar calls for every visible cell. Combine it freely with bounds.

Direction:
Locale:
<Calendar
    isDateUnavailable={(date) => {
        const dow = date.toDate('UTC').getUTCDay();
        return dow === 0 || dow === 6; // disable weekends
    }}
/>

Custom day rendering

Use the renderDay slot to overlay availability dots, prices, or per-day decorations. The renderer receives a DayContext:

FieldTypeWhen true
dateCalendarDateThe cell's date
isOutsideMonthbooleanCell belongs to the previous / next month
isTodaybooleanMatches today() in the local timezone
isSelectedbooleanSelected (single / multi) or range endpoint
isRangeStartbooleanRange-mode start
isRangeEndbooleanRange-mode end
isInRangebooleanStrictly between start and end
isInPreviewRangebooleanHover preview between start and pointer
isUnavailablebooleanOutside min/max or isDateUnavailable true
isFocusedbooleanHas keyboard focus
isWeekendbooleanLocale-derived weekend day
Direction:
Locale:

Display options

A handful of small toggles that don't fit elsewhere:

<Calendar showWeekNumbers />     // adds an ISO week-number column
<Calendar highlightToday={false} /> // disables today's accent (default: on)

showWeekNumbers adds a left column rendering ISO 8601 week numbers for each visible row — common in scheduling UIs and European calendars. highlightToday paints today's cell with the accent color; turn it off in calendars where "today" isn't a meaningful anchor (e.g. browsing historical dates).

Internationalization

Calendar reads its locale from NoriProvider.locale, falling back to new Intl.DateTimeFormat().resolvedOptions().locale. You can also override per-component:

<Calendar locale="de-DE" />

The active locale drives weekday names, weekday order, weekend marking, and (via Select's locale-aware sort) the dropdown captions.

Overriding locale defaults

firstDayOfWeek and weekendDays are derived from the locale via CLDR; both are overridable when you need to match a specific business calendar:

// Always start the week on Monday, regardless of locale
<Calendar firstDayOfWeek={1} />
 
// Treat Friday + Saturday as the weekend (e.g., MENA defaults)
<Calendar weekendDays={[5, 6]} />

firstDayOfWeek and weekendDays are 0..6 where 0 = Sunday.

onChange metadata

Every change fires onChange(value, meta) where meta describes how the change happened:

type ChangeMeta = {
    view: 'day' | 'month' | 'year';
    source: 'click' | 'keyboard' | 'scroll';
};

Use it to decide whether to dismiss a popover (source === 'click') versus keep it open during keyboard navigation, or to know if a year-view selection completed a date pick.

Scroll behavior

By default <Calendar> renders one month at a time (paged) with prev/next chevrons in the header. Set behavior="scroll" to render a vertically scrollable list of month panels instead — handy on touch surfaces and for date pickers where the user is browsing rather than navigating.

Direction:
Locale:
import { Calendar } from '@nori-ui/core';
import { getLocalTimeZone, today } from '@internationalized/date';
 
<Calendar behavior="scroll" defaultValue={today(getLocalTimeZone())} />

When behavior="scroll" is active:

  • The header chevrons advance the focused month one panel at a time — the list scrolls to bring the new month into view rather than swapping the panel in place.

  • The dropdown caption (caption="dropdown") also scrolls the list to the chosen month / year instead of paging.

  • visibleMonths is ignored — scroll mode is single-column by definition. Passing visibleMonths > 1 together with behavior="scroll" logs a development-mode warning and falls back to single column.

  • On native, scroll mode requires the optional peer dependency @marceloterreiro/flash-calendar:

    yarn add @marceloterreiro/flash-calendar

    Web doesn't require any additional dependency — the scroll container is a plain <div> and focused-month tracking uses IntersectionObserver.

Keyboard

KeyAction
/ Move focus by 1 day
/ Move focus by 1 week
PgUp / PgDnMove focus by 1 month
Shift + PgUp/DnMove focus by 1 year
Home / EndMove to start/end of week
Enter / SpaceSelect focused date

Disabled cells are skipped during keyboard navigation. If every cell in the direction of motion is disabled, focus stays put.

Props

<Calendar>

PropTypeDefaultDescription
behaviorenumDefault 'paged' on web, 'scroll' on native (Phase 2).
captionenumHeader layout. `'title'` (default) shows a centered "May 2026 ▾" drilldown button. `'dropdown'` replaces it with `[ May ▾ ] [ 2026 ▾ ]` pickers powered by `Select`. `'custom'` hides the built-in caption and renders the Calendar's children — typically wrapped in `<Calendar.Caption>` — driving them via `useCalendarCaption()`.
classNamestring
defaultValueCalendarDate | CalendarDate[] | DateRange | null
defaultViewenum
firstDayOfWeekenumOverride locale firstDayOfWeek (0=Sun..6=Sat).
highlightTodaybooleanHighlight today's cell. @defaultValue true
isDateUnavailable(date: CalendarDate) => booleanPredicate marking a date unavailable (cannot be focused or selected).
localestringOverride `NoriProvider.locale`.
maxNightsnumberRange mode only — maximum nights between start and end.
maxValueCalendarDateInclusive maximum selectable date.
minNightsnumberRange mode only — minimum nights between start and end.
minValueCalendarDateInclusive minimum selectable date.
modeenum
onChange(value: CalendarValue<M>, meta: ChangeMeta) => void
onViewChange(view: CalendarView) => void
refRef<View>
renderDay(ctx: DayContext) => ReactNodeCustom renderer for a single day cell.
showWeekNumbersbooleanRender the ISO week number column.
testIDstringTest id for the root element.
valueCalendarDate | CalendarDate[] | DateRange | null
viewenum
visibleMonthsnumber | "auto"Number of calendar months side-by-side. `'auto'` = 2 on ≥768px, else 1.
weekendDaysDayOfWeek[]Override locale weekend marking.
yearRange[min: number, max: number]Year-dropdown bounds, inclusive. Only honored when `caption !== 'title'`. Defaults derive from `minValue` / `maxValue` if set, else `[focused.year - 100, focused.year + 10]` — covers the common birthday-picker and short-term-booking ranges.

<Calendar.Caption>

PropTypeDefaultDescription
childrenReactNodeCustom caption content — typically your own month / year selects.
Preview theme