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.
Playground
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.
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.
Loading
Use CalendarSkeleton as a placeholder while date data loads.
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 on desktop; every tappable cell floors at 44px below md (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) |
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" | "ltr" | Flips arrows and layout; RTL defaults locale to Arabic |
locale | string | "en-US" | BCP-47 tag for caption, dropdowns and weekday cells (western digits kept) |
strings | Partial<CalendarStrings> | — | Override any chrome label — nav, dropdowns, panel, dayMarked, today |
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
- 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 below
md; the panel's inputs hold 16px text on phones 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-footer, calendar-today,
calendar-datetime, 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;
}