Photon Design System

Calendar

A month of days, laid out to read or to choose from. Built on React DayPicker with Photon’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.

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.

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.

<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.

<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.

September 2026
<Calendar mode="single" captionLayout="label" />

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.

Pick the day it renews.

Anatomy

  • root — the calendar box; months inside it holds one month per 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 classNames key of its own — classNames.today marks today’s day cell in the grid, not the button; rename the button with todayLabel, or replace the row with components.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 (default 1)
  • 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 Photon’s own jump views; label for a caption that only reads; dropdown · dropdown-months · dropdown-years for DayPicker’s native selects
  • showOutsideDays — boolean (default true) — Photon turns this on; DayPicker’s own default is off
  • weekStartsOn / locale / dir — localization
  • footer — ReactNode
  • todayLabel — string (default Today) — the word on the way back, used as its label and its tooltip
  • classNames / components — merge over Photon’s own, element by element

Accessibility

  • The grid is a real table. role="grid", a labelled role="gridcell" per day, and aria-selected on 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 labelled role="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 todayLabel as both its accessible name and its tooltip, and dims like the arrows do when today is past startMonth / endMonth.
  • The month asked for is the month shown. startMonth / endMonth bound the year list, and left unset they widen to hold whatever defaultMonth or month names, so a calendar opened on 1979 opens on 1979.

Best practices

  • Bound the calendar to what the system will accept. disabled with 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 DatePicker when 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 Date objects in state, not strings. Format at the edge, with Intl.DateTimeFormat, so the viewer’s locale decides the words.