DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CalendarDate PickerTime PickerTime RangeTime Slots
AI agentsContributing
  1. Components
  2. Calendar

Calendar

Date grid built on react-day-picker v9. Single, range and multiple selection, dropdown month/year captions, day markers and per-day content, unavailable days, RTL and localized chrome, week numbers, multi-month, an optional date-time panel, and a skeleton twin.

$ pnpm add @tessinaui/ui

When to use

  • Seeing a date in context. People need to see how a day relates to its neighbours: booking, scheduling, availability, a month of activity.
  • Picking one date, a range or several dates from a grid that stays visible (mode="single", "range", "multiple").
  • Blocking days up front. disabled for days that cannot be reached, unavailableDates for days that are taken but should stay announced.
  • Annotating days. markers and renderDayExtra carry a status, price or count on each cell.
  • A read-only month grid. Omit mode for a display-only calendar.
  • Dates near today. For far-off dates, use the month and year dropdowns.

When not to use

  • For a date people already know, such as a date of birth. Use typed date fields; the grid is slower than typing.
  • For a form field that opens a calendar. Use a DatePicker, which opens this grid in a popover and can anchor to a typed input.
  • For a time of day. Use a TimePicker, or TimeSlots to book from available slots.
  • For a span of clock times. Use a TimeRange.
  • For relative moments such as "tomorrow" or "in a week". Offer a short list of presets instead of a grid.
  • As the only way to enter a date. Pair it with an input for typing, since some people find a grid hard to use.

Installation

Terminal
pnpm add @tessinaui/ui react-day-picker

Usage

example.tsx
import { Calendar } from "@tessinaui/ui";
example.tsx
{/* Single date */}
<Calendar mode="single" selected={date} onSelect={setDate} />

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

{/* Multiple dates */}
<Calendar mode="multiple" selected={dates} onSelect={setDates} />

A calendar with no mode (and no onDayClick) renders a display-only grid: day cells are plain text, not buttons. markers, renderDayExtra and unavailableDates need the interactive grid, so pass a mode with them — the component warns in development if you don't.

Examples

Default

A single-date picker — the simplest real usage.

Range

Select a start and end date across two months with mode="range".

Range limits

min/max bound the span in days and excludeDisabled truncates a range that would swallow a disabled day.

Multiple

Pick several independent dates with mode="multiple".

Required

required makes the selection non-clearable — re-clicking the selected day keeps it, so onSelect never reports undefined once a value exists.

Month and year dropdowns

captionLayout="dropdown" swaps the caption text for month + year selects — the far-jump layout for dates of birth and archives. dropdown-months / dropdown-years render just one select; reverseYears lists years newest first. Option ranges follow fromDate/toDate (default: the last 100 years).

Markers

markers paints a dot under matching days. A label on an entry names its meaning twice from one source: in the day's accessible name ("…, Deadline") and in the showLegend footer row of intent-coloured swatches — so the legend can never drift from the grid. Unlabelled entries fall back to the generic dayMarked suffix and stay out of the legend.

Day content

renderDayExtra renders a second line inside every day cell — prices, availability, counts. Days where the callback returns null keep a muted "–" so the day numbers stay aligned across the row. Combined with unavailableDates the line can label sold-out days ("Sold") — the strike hits the day number only. The line is aria-hidden; keep essential figures available elsewhere.

Trade / P&L heat map

Custom modifiers + modifiersClassNames tint the day fills, renderDayExtra prints the value — the merge pipeline means this is pure composition, no component changes.

Goal rings

A components.DayButton override draws an SVG progress ring behind each number (fitness-app style) while markers, selection and keyboard behaviour keep working. Pairs well with selectionVariant="outline".

Size the ring explicitly — absolute inset-0 size-full p-1, the gap as padding. iOS Safari sizes an absolutely positioned <svg> with an auto width to its containing block and ignores the end-side inset, so absolute inset-1 alone drew a cell-sized ring 4px low-right of the number on every iPhone while Chrome, Firefox and WebKit trunk stretched it to the inset box.

The ring is centred on the button, so the number has to be too: keep the house number-span classes. tabular-nums centres each digit in one shared advance (Inter's proportional 1 is narrow and sits left of its own), and the absolute + translate centring exists for phones: iOS WebKit snaps a layout-positioned text run to whole device pixels, so a fractional half-advance floored the number up to a device pixel high-left of the ring. A composited transform (will-change-transform forces the layer) rasters the glyphs at an integer origin and places the layer at the true fractional centre.

Booked (unavailable) days

unavailableDates is the reachable but taken state: struck through, still focusable (screen readers can land on them and hear aria-disabled), and never selectable. Compare disabled — the greyed can't reach state.

Today button and footer

showTodayButton renders a footer action that returns to the current month and hides itself when today sits outside fromDate/toDate. The footer slot composes next to it.

Date and time

showDateTime adds Start and End wells under the grid, each a date and a time, and an Apply button. onApply receives the dates, the times and, with showTimezone, the time zone. The zone select starts on the browser's zone unless you control it with timezone and onTimezoneChange. dateTimeLayout="horizontal" puts the panel beside the grid from md up and stacks it below on narrower screens.

Week numbers

showWeekNumber adds the gutter column; ISOWeek switches numbering and the week start to ISO-8601.

Localization

locale drives the caption, the dropdowns and the weekday cells through Intl; dir="rtl" alone defaults to Arabic. strings overrides any chrome label. For full control of react-day-picker's own day labels, pass its labels/locale objects — they merge with ours.

Sizes

Three day-cell densities — sm, md, and lg.

Disabled dates

Block specific days from selection — here weekends are disabled.

Error

errorMessage explains why a pick is invalid. It renders below the month in a polite live region that is mounted from first render — so the message is announced when it appears — marks every month grid aria-invalid, and joins the message into the grid's aria-describedby. The frame takes the error border. Validation stays with you: pick a weekend day here. When the rule is known up front, prefer disabled (above).

Loading

Use CalendarSkeleton as a placeholder while date data loads.

On color

On a coloured surface the calendar keeps its own page panel. It resets the tone inside that panel, so days, buttons, the error text and focus rings render exactly as they do on the page. It has no tone prop and takes none from the surface. Keep the frame: with its background stripped (bg-transparent), the grid sits on the coloured fill, and the selected day can match it.

RTL

dir="rtl" mirrors the grid, so the week runs right to left, swaps the month arrows, and switches the caption, weekday names and button labels to Arabic. Day numbers stay in Western digits, and locale picks another language. Without dir, Calendar inherits the page's direction and mirrors the same way, but keeps English strings: only an explicit dir="rtl" switches them to Arabic.

API Reference

Calendar props

All react-day-picker DayPicker props pass through in addition to the props below — components, formatters and classNames merge per key with the house implementation instead of replacing it wholesale.

Style

PropTypeDefaultDescription
size"sm" | "md" | "lg""md"Day-cell density: 32/36/40px with a mouse; every tappable cell floors at 44px on touch — a phone, or a coarse pointer at any width (WCAG 2.5.5).
width"narrow" | "default" | "wide""default"Container padding. Phones always use the narrow p-1 so the 44px grid fits 320px; desktop gets p-1 / p-3 / p-5.
rounded"none" | "sm" | "md" | "lg" | "xl" | "full""md"Frame radius 0/8/12/16/24/24px — full is the §2 panel cap (24px); day buttons go fully circular there.
fullWidthbooleanfalseGrid stretches to the container. Replaces stretch (deprecated alias, removal next minor).
classNamestring—Added to the outer frame. Other HTML attributes (data-*, aria-*, id, style) land on react-day-picker's grid root, mirroring the form-control forwarding exception.

Selection

PropTypeDescription
mode"single" | "range" | "multiple"Selection behaviour. Omit for a display-only grid.
selectedDate | DateRange | Date[]Controlled selected value
onSelect(value, triggerDate, modifiers, e) => voidSelection callback. Never fires for unavailableDates.
requiredbooleanNon-clearable selection (per mode)
errorMessageReactNodeError text below the calendar. Announced politely, joined into each month grid's aria-describedby, and marks the grids aria-invalid
min / maxnumbermultiple: selection count bounds · range: span bounds in days (endpoints counted)
excludeDisabledbooleanrange: truncate instead of spanning a disabled day
defaultMonthDateMonth shown on first render
todayDateOverride "today" (deterministic tests, server clocks)
selectionVariant"fill" | "outline" | "line"How selection paints: solid chip + band (default) · 2px ring, no fill · underline bar (ranges draw one continuous line). Clicking an adjacent-month day navigates to that month.

Constraints

PropTypeDescription
fromDate / toDateDateInclusive earliest/latest selectable day. Clamps navigation to the bound months and disables out-of-range days inside them.
startMonth / endMonthDateNavigation bounds only (no day disabling) — take precedence over the pair above for navigation.
disabledMatcher | Matcher[]Not selectable, greyed, skipped by pointer. Merged with the fromDate/toDate clamp.
unavailableDatesMatcher | Matcher[]Focusable but not selectable — struck through, aria-disabled (booked / sold out).
hiddenMatcher | Matcher[]Days removed from the grid entirely.

Matcher is react-day-picker's vocabulary — a Date, Date[], { from, to }, { before }, { after }, { dayOfWeek: number[] }, or a (date: Date) => boolean predicate. Constraints govern this grid only — a paired text input (DatePicker) still needs its own validation.

Caption & navigation

PropTypeDefaultDescription
captionLayout"label" | "dropdown" | "dropdown-months" | "dropdown-years""label"Caption chrome. Dropdown modes keep the chevrons for single steps.
reverseYearsbooleanfalseYear dropdown descending (date-of-birth)
month / onMonthChangeDate / (m: Date) => void—Controlled displayed month
showTodayButtonbooleanfalseFooter "Today" action; auto-hidden when today is out of bounds
disableNavigationbooleanfalseLock the displayed month
pagedNavigationbooleanfalseMulti-month: advance by the whole page

Layout

PropTypeDefaultDescription
numberOfMonthsnumber1Months side by side — stacks vertically on phones
showOutsideDaysbooleantrueShow adjacent-month days
fixedWeeksbooleanfalseAlways 6 rows — no height jitter between months
showWeekNumberbooleanfalseWeek-number gutter column
ISOWeekbooleanfalseISO-8601 weeks (Monday start, ISO numbering)
weekStartsOn0–6localeFirst day of week
hideWeekdaysbooleanfalseRemove the weekday header row

Day content

PropTypeDescription
markersMatcher | { match: Matcher; intent?: "none" | "error" | "warning" | "success" | "info"; label?: string }[]Dot under matching days; label becomes the accessible-name suffix and the legend entry
showLegendbooleanFooter swatch row from the labelled markers entries (composes with footer/showTodayButton)
renderDayExtra(date, modifiers) => ReactNodeSecond line on every day cell (aria-hidden); nullish returns render a muted "–" so numbers align; cells grow to fit
footerReactNodeRow under the grid — plain content, not a live region

Localization

PropTypeDefaultDescription
dir"ltr" | "rtl"—Flips arrows and layout; an explicit "rtl" defaults locale to Arabic. Unset, it inherits the page's direction
localestring"en-US"BCP-47 tag for caption, dropdowns and weekday cells (western digits kept)
stringsPartial<CalendarStrings>English, Arabic under dir="rtl"Override any chrome label — nav, dropdowns, panel, today — and the day buttons' names: dayToday ("Today, {date}"), daySelected, dayMarked and dayWithMarker ("{date}, {marker}"), plus weekNumber / weekNumberHeader. The language follows locale; English calendars keep react-day-picker's own day names
timeZonestring—react-day-picker time-zone handling — detect on the client (in an effect) to avoid hydration mismatch
numeralsNumerals"latn"Digit system for day numbers

Date & Time Panel

PropTypeDefaultDescription
showDateTimebooleanfalseBuilt-in Start/End wells with Apply — the date well opens a nested house calendar, the time well is the library TimePicker (popover wheel); both follow rounded
dateTimeLayout"vertical" | "horizontal""vertical"Panel below or beside the grid (side-by-side stacks on phones)
showTimebooleantrueTime inputs next to the dates
showTimezonebooleanfalseSelectable IANA timezone under Apply (platform zone list; detected zone by default)
timezone / onTimezoneChangestring / (zone) => voiddetectedControlled zone for the footer select; the choice rides CalendarApplyData.timezone
onApply(data: CalendarApplyData) => void—Apply click. In multiple mode data.dates carries every pick, sorted.

Typing an end date before the start swaps the endpoints; a start typed past the end restarts the range. A selection that carries a real time of day seeds the time inputs.

CalendarSkeleton props

PropTypeDefaultDescription
size / width / rounded / dir / numberOfMonths / fullWidthas Calendar—The placeholder is the calendar's own box (§2b)
monthDatetodayMonth the placeholder stands in for — drives the week-row count
weeksnumberderivedPin the row count (pass 6 to mirror fixedWeeks)
weekStartsOn0–60Keeps the derived row count in step with the real grid

Accessibility

VoiceOver + Safari: passed 2026-09-14. The selected day reads "Wednesday, January 15th, 2025, selected, button" with "January 2025, table, 7 columns, 5 rows"; arrows read full day names ("Thursday, January 16th, 2025"), and PageDown announces the new month, "February 2025". NVDA and JAWS are untested.

  • Grid semantics come from react-day-picker: role="grid", one roving tab stop, and full day names on every cell ("Friday, August 14th, 2026").
  • Keyboard: ←/→ day (RTL-aware) · ↑/↓ week · PageUp/PageDown month · Shift+PageUp/Shift+PageDown year · Home/End week bounds · Enter/Space select. Caption dropdowns are native selects.
  • One live region: the first month's caption announces navigation (role="status"); dropdown captions announce through the selects instead.
  • Unavailable ≠ disabled: unavailable days stay in the focus order and expose aria-disabled; disabled days are removed from interaction.
  • Every interactive cell floors at 44×44px on touch devices, and the caption selects, the time zone select and the panel's wells hold 16px text there, so iOS doesn't zoom.
  • The today ring uses the hard-gated --outline-border (≥3:1 both themes); marker dots are supplementary — the accessible name carries the information.

Agent notes (data attributes)

data-slot hooks: calendar, calendar-caption, calendar-caption-label, calendar-nav-previous/next, calendar-dropdown, calendar-day, calendar-day-extra, calendar-legend, calendar-footer, calendar-today, calendar-error-region (always mounted) and calendar-error, calendar-datetime, calendar-timezone, calendar-apply, calendar-skeleton. Day buttons expose data-selected / data-today / data-outside / data-disabled / data-range-start/middle/end / data-unavailable / data-marked when markers, day content or unavailable dates are active.

CalendarApplyData type

example.ts
import type { CalendarApplyData } from "@tessinaui/ui";

interface CalendarApplyData {
  start?: Date;
  startTime?: string;  // "HH:MM"
  end?: Date;
  endTime?: string;    // "HH:MM"
  dates?: Date[];      // multiple mode: every pick, sorted
  timezone?: string;   // showTimezone: the selected IANA zone
}

DateRange type

example.ts
import type { DateRange } from "@tessinaui/ui";

interface DateRange {
  from: Date | undefined;
  to?: Date | undefined;
}
SegmentedControlDate Picker

On this page

When to useWhen not to useInstallationUsageExamplesDefaultRangeRange limitsMultipleRequiredMonth and year dropdownsMarkersDay contentTrade / P&L heat mapGoal ringsBooked (unavailable) daysToday button and footerDate and timeWeek numbersLocalizationSizesDisabled datesErrorLoadingOn colorRTLAPI ReferenceCalendar propsStyleSelectionConstraintsCaption & navigationLayoutDay contentLocalizationDate & Time PanelCalendarSkeleton propsAccessibilityAgent notes (data attributes)CalendarApplyData typeDateRange type