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/uiWhen 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.
disabledfor days that cannot be reached,unavailableDatesfor days that are taken but should stay announced. - Annotating days.
markersandrenderDayExtracarry a status, price or count on each cell. - A read-only month grid. Omit
modefor 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
pnpm add @tessinaui/ui react-day-pickerUsage
import { Calendar } from "@tessinaui/ui";{/* 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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
fullWidth | boolean | false | Grid stretches to the container. Replaces stretch (deprecated alias, removal next minor). |
className | string | — | 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
| Prop | Type | Description |
|---|---|---|
mode | "single" | "range" | "multiple" | Selection behaviour. Omit for a display-only grid. |
selected | Date | DateRange | Date[] | Controlled selected value |
onSelect | (value, triggerDate, modifiers, e) => void | Selection callback. Never fires for unavailableDates. |
required | boolean | Non-clearable selection (per mode) |
errorMessage | ReactNode | Error text below the calendar. Announced politely, joined into each month grid's aria-describedby, and marks the grids aria-invalid |
min / max | number | multiple: selection count bounds · range: span bounds in days (endpoints counted) |
excludeDisabled | boolean | range: truncate instead of spanning a disabled day |
defaultMonth | Date | Month shown on first render |
today | Date | Override "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
| Prop | Type | Description |
|---|---|---|
fromDate / toDate | Date | Inclusive earliest/latest selectable day. Clamps navigation to the bound months and disables out-of-range days inside them. |
startMonth / endMonth | Date | Navigation bounds only (no day disabling) — take precedence over the pair above for navigation. |
disabled | Matcher | Matcher[] | Not selectable, greyed, skipped by pointer. Merged with the fromDate/toDate clamp. |
unavailableDates | Matcher | Matcher[] | Focusable but not selectable — struck through, aria-disabled (booked / sold out). |
hidden | Matcher | 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
| Prop | Type | Default | Description |
|---|---|---|---|
captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | Caption chrome. Dropdown modes keep the chevrons for single steps. |
reverseYears | boolean | false | Year dropdown descending (date-of-birth) |
month / onMonthChange | Date / (m: Date) => void | — | Controlled displayed month |
showTodayButton | boolean | false | Footer "Today" action; auto-hidden when today is out of bounds |
disableNavigation | boolean | false | Lock the displayed month |
pagedNavigation | boolean | false | Multi-month: advance by the whole page |
Layout
| Prop | Type | Default | Description |
|---|---|---|---|
numberOfMonths | number | 1 | Months side by side — stacks vertically on phones |
showOutsideDays | boolean | true | Show adjacent-month days |
fixedWeeks | boolean | false | Always 6 rows — no height jitter between months |
showWeekNumber | boolean | false | Week-number gutter column |
ISOWeek | boolean | false | ISO-8601 weeks (Monday start, ISO numbering) |
weekStartsOn | 0–6 | locale | First day of week |
hideWeekdays | boolean | false | Remove the weekday header row |
Day content
| Prop | Type | Description |
|---|---|---|
markers | Matcher | { match: Matcher; intent?: "none" | "error" | "warning" | "success" | "info"; label?: string }[] | Dot under matching days; label becomes the accessible-name suffix and the legend entry |
showLegend | boolean | Footer swatch row from the labelled markers entries (composes with footer/showTodayButton) |
renderDayExtra | (date, modifiers) => ReactNode | Second line on every day cell (aria-hidden); nullish returns render a muted "–" so numbers align; cells grow to fit |
footer | ReactNode | Row under the grid — plain content, not a live region |
Localization
| Prop | Type | Default | Description |
|---|---|---|---|
dir | "ltr" | "rtl" | — | Flips arrows and layout; an explicit "rtl" defaults locale to Arabic. Unset, it inherits the page's direction |
locale | string | "en-US" | BCP-47 tag for caption, dropdowns and weekday cells (western digits kept) |
strings | Partial<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 |
timeZone | string | — | react-day-picker time-zone handling — detect on the client (in an effect) to avoid hydration mismatch |
numerals | Numerals | "latn" | Digit system for day numbers |
Date & Time Panel
| Prop | Type | Default | Description |
|---|---|---|---|
showDateTime | boolean | false | Built-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) |
showTime | boolean | true | Time inputs next to the dates |
showTimezone | boolean | false | Selectable IANA timezone under Apply (platform zone list; detected zone by default) |
timezone / onTimezoneChange | string / (zone) => void | detected | Controlled 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
| Prop | Type | Default | Description |
|---|---|---|---|
size / width / rounded / dir / numberOfMonths / fullWidth | as Calendar | — | The placeholder is the calendar's own box (§2b) |
month | Date | today | Month the placeholder stands in for — drives the week-row count |
weeks | number | derived | Pin the row count (pass 6 to mirror fixedWeeks) |
weekStartsOn | 0–6 | 0 | Keeps 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/PageDownmonth ·Shift+PageUp/Shift+PageDownyear ·Home/Endweek bounds ·Enter/Spaceselect. 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
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
import type { DateRange } from "@tessinaui/ui";
interface DateRange {
from: Date | undefined;
to?: Date | undefined;
}