Calendar
A month of days, laid out to read or to choose from. Built on
React DayPicker with Pho’s own class map, so the
library’s stylesheet is never imported and no rdp-* class reaches the page.
Every DayPicker prop passes through. For a calendar behind a field — a trigger
that shows the chosen date and opens this in a popover — use DatePicker.
Import
import { Calendar } from "@photon-ai/pho-ui/components/calendar";
Basic
mode="single" picks one day. Clicking the chosen day again clears it, which
is how an optional date gets unset. Between the month arrows sits a dot: the
way back to today’s month, whatever the viewer has wandered into.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
const [selected, setSelected] = useState<Date>();
<Calendar mode="single" selected={selected} onSelect={setSelected} />;
Selection follows DayPicker’s own contract: hand it onSelect and the calendar
is controlled, so selected has to come with it. Leave both off and the
calendar keeps the selection itself.
Range
mode="range" picks a span. The days between both ends carry a band; the ends
themselves take the action fill. numberOfMonths shows more than one month at
a time, side by side once there is room and stacked below sm.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
const [range, setRange] = useState<DateRange>();
<Calendar
mode="range"
numberOfMonths={2}
selected={range}
onSelect={setRange}
/>;
A range needs two clicks. The first already reads as a complete one-day span
(from and to both set), so watch the clicks rather than the value if you
need to know when the viewer is done.
Multiple
mode="multiple" collects days that need not touch. min and max bound how
many.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<Calendar mode="multiple" max={5} selected={days} onSelect={setDays} />
Bounds
disabled takes a matcher, or a list of them: a Date, a { before } /
{ after } interval, a { dayOfWeek } set, or a predicate. startMonth and
endMonth stop the arrows and set how far the year list reaches; left unsaid,
it runs ten years either side of today.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<Calendar
mode="single"
startMonth={new Date(2026, 0)}
endMonth={new Date(2026, 11)}
disabled={[{ dayOfWeek: [0, 6] }, { before: new Date() }]}
selected={selected}
onSelect={setSelected}
/>
The caption jumps
The month and the year in the caption are each a button into their own view, so a date two years out is two taps rather than twenty-four. The month opens the twelve months and lands straight back on the days. The year opens the years, and because a year alone does not name a day, it asks for the month next. The panel keeps its shape through all three, and the arrows step aside while a list is up.
Ten years either side of today are on offer unless startMonth / endMonth
say otherwise.
captionLayout="label" prints the month and year as words instead, with no
way in. Use it where the dates in play are within a month or two of today and
the arrows are enough. Naming any other captionLayout hands the caption back
to DayPicker and its native select dropdowns.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
<Calendar mode="single" captionLayout="label" />
Footer
footer prints a line under the grid in a live region, which is where a
consequence belongs — what the choice means, not what to do next.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
Anatomy
- root — the calendar box;
monthsinside it holds onemonthper month shown and anchors the nav - month_caption — the month and the year, each a button into its own view
- nav → button_previous / button_next — one pair for the whole strip, in
the top right, with the way back to today between them. That dot is not a
classNameskey of its own —classNames.todaymarks today’s day cell in the grid, not the button; rename the button withtodayLabel, or replace the row withcomponents.Nav - month_grid → weekdays → weekday — the table and its two-letter column heads, or the months / years list standing in its place
- weeks → week → day → day_button — a row per week, a cell per day, and the button that takes the click
Props
Every DayPicker prop passes through. The ones that come up most:
- mode —
single·multiple·range— omit it for a calendar that only shows - selected / onSelect — the selection and its handler; pass both or neither
- numberOfMonths —
number(default1) - defaultMonth / month / onMonthChange — which month is on screen
- startMonth / endMonth —
Date— the far ends of navigation, and the span the year list offers (default: ten years either side of today) - disabled / hidden —
Matcher | Matcher[]— days that refuse a click, and days that are not drawn at all - captionLayout — leave it unset for Pho’s own jump views;
labelfor a caption that only reads;dropdown·dropdown-months·dropdown-yearsfor DayPicker’s native selects - showOutsideDays —
boolean(defaulttrue) — Pho turns this on; DayPicker’s own default is off - weekStartsOn / locale / dir — localization
- footer —
ReactNode - todayLabel —
string(defaultToday) — the word on the way back, used as its label and its tooltip - classNames / components — merge over Pho’s own, element by element
Accessibility
- The grid is a real table.
role="grid", a labelledrole="gridcell"per day, andaria-selectedon the ones that are. - One tab stop. Tab reaches the grid once; arrows move between days, Home and End jump to the ends of the week, Page Up and Page Down change month.
- The caption announces. Its two buttons carry
aria-expanded, and each jump list is a labelledrole="group", so the view that opened is named. - State is never colour alone. Today carries a hairline ring, the selection a filled surface, and both are marked in the accessibility tree.
- The dot says what it is. The way back to today carries
todayLabelas both its accessible name and its tooltip, and dims like the arrows do when today is paststartMonth/endMonth. - The month asked for is the month shown.
startMonth/endMonthbound the year list, and left unset they widen to hold whateverdefaultMonthormonthnames, so a calendar opened on 1979 opens on 1979.
Best practices
- Bound the calendar to what the system will accept.
disabledwith a{ before }matcher beats an error message after the click. - The way back to today stays lit while sitting on today’s month, where the click does nothing. A control that reads as broken on first sight is worse than one that occasionally has nothing to do.
- Reach for
DatePickerwhen the date is one field among several; a bare calendar suits a page whose whole job is picking a day. - Two months for a range, one for a day. A second month costs 250px and only earns it when the viewer is spanning weeks.
- Keep
Dateobjects in state, not strings. Format at the edge, withIntl.DateTimeFormat, so the viewer’s locale decides the words.