Calendar

A month of days, for picking one, several, or a range.

A month of days, for picking one, several, or a span with two ends.

It carries no date library. Everything a month grid needs is arithmetic on the platform's own Date, and shipping a timezone database to render seven columns is a poor trade in a library whose entire runtime is three small packages. Month and weekday names come from Intl, guarded — builds without the full locale data fall back to English rather than taking the screen down over a month name.

Installation

Calendar ships with the library — no separate install.

import { Calendar, Card, Text } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add calendar

Usage

const [day, setDay] = useState<Date>();

<Calendar selected={day} onSelect={setDay} />

// A span, which takes two taps to complete.
const [range, setRange] = useState<DateRange>();

<Calendar mode="range" selected={range} onSelect={setRange} />

Composition

<Calendar>
  <Calendar.Grid>…</Calendar.Grid>
  <Calendar.Day>…</Calendar.Day>
</Calendar>
  • Calendar.Grid — One month: the weekday headings and six rows of days. Rendered for you per numberOfMonths; exported for a layout that needs the grid without the caption.
  • Calendar.Day — One cell — the number, the disc when it is selected, and the band when it is inside a range.

Examples

A single day

Tapping the selected day again clears it, so there is a way back to nothing without a separate control.

const [day, setDay] = useState<Date>();

<Calendar selected={day} onSelect={setDay} />

A range

Two taps. A third starts again, and a tap that lands before the open end becomes the new start rather than being refused — someone who picked the wrong month first should not have to undo before retrying.

const [range, setRange] = useState<DateRange>();

<Calendar mode="range" selected={range} onSelect={setRange} />

Several days

multiple collects a set. Tapping a day already in it takes it out.

const [days, setDays] = useState<Date[]>([]);

<Calendar mode="multiple" selected={days} onSelect={setDays} />

Days ruled out

disabled takes a list, a span, or a rule. A rule, because "no weekends" and "nothing in the past" are the two common cases and neither can be written as a list that is still right tomorrow.

<Calendar
  selected={day}
  onSelect={setDay}
  minDate={new Date()}
  disabled={(date) => date.getDay() === 0 || date.getDay() === 6}
/>

Month and year pickers

captionLayout="dropdown" is the difference between choosing a birthday in four taps and in four hundred.

<Calendar
  selected={day}
  onSelect={setDay}
  captionLayout="dropdown"
  maxDate={new Date()}
  defaultMonth={new Date(1996, 5, 1)}
/>

Two months at once

What picking a range across a month boundary wants. The arrows split across the row — back on the first caption, forward on the last — so there is one way to page rather than two.

<Calendar mode="range" numberOfMonths={2} selected={range} onSelect={setRange} />

Hijri or Gregorian

calendar moves the grid, not only the labels — a Hijri month starts on a different day and runs 29 or 30. The value stays a plain Date either way, so the choice is a presentation one and nothing downstream has to know which was on screen.

<Calendar selected={day} onSelect={setDay} calendar="islamic" />

{/* Or follow the device, for an app whose language and calendar are the
    same question. */}
<Calendar selected={day} onSelect={setDay} calendar="auto" />

API Reference

Calendar

PropTypeDefaultDescription
classNamestring
modeMode'single' as ModeOne day, several, or a span with two ends.
selectedCalendarSelection[Mode]Controlled selection. Its shape follows mode.
defaultSelectedCalendarSelection[Mode]Starting selection when uncontrolled.
onSelect(selected: CalendarSelection[Mode]) => void
monthDateControlled visible month. Any day inside it will do.
defaultMonthDateMonth shown first when uncontrolled. Defaults to the selection, or today.
onMonthChange(month: Date) => void
numberOfMonthsnumber1Months side by side. Two is what picking a range across a boundary wants.
captionLayoutCalendarCaptionLayout'label'dropdown turns the caption into month and year pickers, which is the difference between choosing a birthday in four taps and in four hundred.
disabledCalendarDisabledDays that cannot be picked: a list, a span, or a rule.
minDateDateEarliest selectable day. Also stops the caption paging back past it.
maxDateDateLatest selectable day.
weekStartsOnnumber00 is Sunday.
showOutsideDaysbooleantrueDraw the neighbouring months' days rather than leaving the cells blank.
localeDateLocaleBCP 47 tag for the month and weekday names. The device's own by default.
calendarCalendarSystem'gregory'Which calendar the months and the day numbers are counted in. gregory by default rather than auto: a grid whose month boundaries move with the device's language is a surprise, and it is the caller — not the locale — who knows whether the dates being picked are Hijri ones. auto follows the locale for the cases where they are the same question.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

The grid is always six weeks. A month needs five rows or six depending on the day it starts on, and drawing only the rows it uses makes the calendar change height as it is paged — everything below jumps, and the days themselves appear to shift between months. The spare cells hold the neighbouring months' days, or are left blank with showOutsideDays={false}. Blank, not absent: the row has to stay seven wide or the columns stop lining up with their headings.

A range is drawn in three pieces. On the web the band under a range is one background with its ends rounded by :first-child and :last-child. There are no pseudo-classes here, so each day works out whether it is a start, a middle or an end — and, separately, whether it is at the edge of its own row, because a range running over a weekend has to close off on Saturday and open again on Sunday rather than trailing into the gap. The band is drawn behind the number rather than as the cell's own background, which is what lets it run from cell to cell.

Days are compared at local midnight. A Date carries a time, and two values on the same day are not equal until it is stripped.

It opens on the selection, not on today. A picker reopened on a date chosen last year lands on that month — otherwise it opens on today and the choice looks lost.

minDate and maxDate bound the paging too, not only the selection: an arrow that pages to a month where every day is dead is an arrow that should have been off.

calendar goes all the way down. It is gregory by default rather than auto, because a grid whose month boundaries move with the device's language is a surprise — and it is the caller, not the locale, who knows whether the dates being picked are Hijri ones. Set islamic and the month anchoring, the paging, the outside-day dimming and the day numbers all count in it, so the caption and the cells can never disagree. Month lengths are asked of the platform's own calendar data a day at a time rather than read from a table, which is what keeps a 29-day month 29 days in the years it is one.

Digits are pinned to Latin. The cells render numbers, so a caption in Arabic-Indic over cells in Latin would be two scripts for one date; both are Latin instead.

Without islamic data in the build, it falls back to Gregorian rather than throwing — wrong, but coherent, which is the same posture the month names already take.

On this page