Date Picker
A text field that shows the chosen date and opens a Calendar beneath it. It
wears Input’s chrome and sizes, so the two line up in one form, and it takes
a typed date as readily as a clicked one. The date reads in figures —
09/12/2026 — because a month’s name is the one part nobody wants to spell. On a touch device it hands off to the
system picker instead. For the calendar on its own, without a field in front of
it, use Calendar.
Import
import { DatePicker } from "@photon-ai/pho-ui/components/date-picker";
Basic
The value is a Date, never a string. Pass value to control it,
defaultValue to let the picker keep its own. Picking a day closes the
popover; clicking the chosen day again clears the field.
Clicking the field does two things at once: it opens the calendar and puts the caret in the field. Clicking again is the viewer reaching for the caret, not a dismissal, so the calendar stays. Escape, a click outside, and a settled answer are what close it.
const [date, setDate] = useState<Date>();
<DatePicker label="Renews on" value={date} onValueChange={setDate} />;
Range
mode="range" picks a span, and the value becomes { from, to }. The popover
stays open through the first click and closes on the second, when both ends are
down. Reopening a settled span starts a new one on the next click rather than
dragging the nearer end out to meet it.
Pick the first day, then the last.
const [range, setRange] = useState<DateRange>();
<DatePicker
mode="range"
label="Billing period"
value={range}
onValueChange={setRange}
calendarProps={{ numberOfMonths: 2 }}
/>;
Named windows
presets offers windows by name beside the calendar — Last 24 hours,
Last 7 days, Last 30 days — which is the menu most activity pages want,
with the calendar right there for the custom span the menu used to hand off
to. Picking one answers the whole question and closes the popover.
The trigger then reads the name, not the dates behind it, because the name is what was chosen. Pick a day by hand and the name drops of its own accord: it stands only while the value is still the one it produced.
<DatePicker
mode="range"
label="Activity window"
presets={[
{
label: "Last 24 hours",
value: () => ({ from: hoursAgo(24), to: new Date() }),
},
{
label: "Last 7 days",
value: () => ({ from: daysAgo(7), to: new Date() }),
},
{
label: "Last 30 days",
value: () => ({ from: daysAgo(30), to: new Date() }),
},
]}
value={range}
onValueChange={setRange}
/>
Pass a function for anything measured from now, as above, and it is resolved
at the click rather than at the render — a window opened at nine and picked at
noon means noon. A fixed { from, to } works too, for a quarter or a billing
period that does not move.
Count a relative window in milliseconds (Date.now() - n * 3600_000) rather
than by winding the local clock back with setHours or setDate: those work
in local time, so a window crossing a daylight-saving change comes out an hour
short or an hour long twice a year.
Single mode takes presets as Dates, for a Today or a Tomorrow.
Time
time asks for the clock as well as the day, as fine as the page needs it:
hour, minute, or second. Everything below the grain is zeroed, so the
Date means exactly what it says. Picking a day no longer closes the popover —
the clock is still to be set, and it sits under the calendar.
<DatePicker label="Starts" time="minute" value={at} onValueChange={setAt} />
The clock carries from day to day: change the date and the time stays put.
Without time, a chosen day is midnight.
A span asks for both ends.
<DatePicker mode="range" time="minute" value={range} onValueChange={setRange} />
Typing
Empty, the field says what shape to type — mm/dd/yyyy, or dd/mm/yyyy,
or yyyy-mm-dd, whichever order locales puts them in. It reads the figures
back in that same order, so what it shows is what it takes: 12/25/2026 is
Christmas in en-US and 25/12/2026 is Christmas in en-GB. A year in two
figures is this century, and a day that is not on the calendar — 02/31 —
commits nothing.
yyyy-mm-dd is read as itself wherever it turns up, since nothing else starts
with four figures, and a date spelled out still parses, so a pasted
Dec 25, 2026 lands.
The calendar follows along to the month being typed. Half a date commits nothing, so the field does not flicker through Decembers on the way to one. Leaving the field puts the figures back in the shape the picker writes them.
mode="range" is a span, a separator and two dates; there is nothing sensible
to type, so that field only opens the calendar.
Arrow Down hands the caret to the grid, where the arrows mean days again.
On a phone
Where the primary input is a finger, the field hands off to the system picker —
the OS wheel or dialog people already know — instead of opening the calendar.
It is the same field either way; only what the tap opens changes. With time
set, the system is asked for the clock too, at the same grain.
Three cases keep the calendar on every device: mode="range", which no system
picker offers; a disabled rule the OS cannot enforce; and presets, whose
rail lives in the popover and would be lost behind the system picker. A first and
last day it can, so { before } and { after } pass straight through as the
picker’s own bounds; a { dayOfWeek } set or a predicate cannot, and those
keep the calendar, which enforces them.
Width
A field fills the column it is in, like every other field. width="hug"
shrinks it to the words it is showing instead, for a picker that sits in a
toolbar or beside a title rather than in a form. It stays wide enough for the
shape to type, so it does not jump when a date answers it.
<DatePicker width="hug" value={date} onValueChange={setDate} />
Span words
Both ends of a span say the year twice, and often the month as well. The field
says it once, at the end that carries it: 09/01–09/08/2026 in en-US,
01–08/09/2026 in en-GB where the day leads, 2026-09-01–08 in
sv-SE where the year does. Nothing shared, nothing dropped —
12/28/2026–01/04/2027 stays whole.
Whole fields only, and only from the end the year sits at, so the half that gets dropped is never the half telling the two dates apart.
A span carrying a clock is printed whole: the tail fields are hours and minutes, so the only thing left to merge is a year that would then print after a time.
Nothing sits around the dash: the field is tight and the dates are already
grouped by their own separators. It is an en dash rather than a hyphen because
a hyphen is one — 2026-09-01-08 reads as three numbers where
2026-09-01–08 reads as two dates.
How long a span may be
maxDays caps it and minDays sets a floor, both counted in days. A pick
that would break either restarts the span from the day just clicked, so the
bound is felt rather than explained — no error to write, no window the page
cannot serve.
<DatePicker
mode="range"
maxDays={7}
label="Activity window"
value={range}
onValueChange={setRange}
/>
These are DayPicker’s own min and max, which live on range mode rather
than on the base props calendarProps carries, so they are named here.
Sizes
Three sizes, matching Input height for height.
States
invalid takes the error ring and turns the hint red. disabled greys the
trigger and stops it opening.
Pick a day after the start.
Bounds
calendarProps is the way through to everything React DayPicker takes: which
days refuse a click, how far the arrows go, how many months to show.
Weekdays only, from tomorrow.
<DatePicker
label="Ship date"
hint="Weekdays only, from tomorrow."
value={date}
onValueChange={setDate}
calendarProps={{
disabled: [{ dayOfWeek: [0, 6] }, { before: new Date() }],
}}
/>
Props
- mode —
single·range(defaultsingle) — decides the value’s shape - value / defaultValue —
Date(orDateRangein range mode) — controlled / uncontrolled selection. Passingvalueat all makes the picker controlled, so an empty controlled field isvalue={undefined} - onValueChange —
(value) => void— fires on every pick, including the one that clears the field - label —
ReactNode— associated with the trigger - hint —
ReactNode— the line under the field; error-colored wheninvalid - placeholder —
string(default: the shape to type,mm/dd/yyyy;Pick a date rangefor a span, which cannot be typed) - invalid / disabled / required —
boolean - size —
sm·md·lg(defaultmd) - width —
fill·hug(defaultfill) —hugshrinks the field to the words it shows - time —
hour·minute·second— ask for the clock too, this fine; omit it and a chosen day is midnight. The field writes it on a 24-hour dial (15:04), which is fewer keystrokes than3:04 PM - locales —
Intl.LocalesArgument— the trigger’s words; defaults to the viewer’s locale - minDays / maxDays —
number— the shortest and longest span, in days; range mode only - presets —
{ label, value }[]— named windows beside the calendar;valueis aDate(or aDateRangein range mode), or a function returning one, resolved at the click - calendarProps — everything else DayPicker takes:
disableddays,startMonth/endMonth,numberOfMonths,weekStartsOn,captionLayout - id / className — for the trigger
formatDateLabel(value, locales?, time?) is the field’s own formatter and
dateFieldMask(locales?, time?) its placeholder, both exported for pages that
print the same value elsewhere.
Accessibility
- The field is a real input. It carries the label through
htmlFor, points at the hint witharia-describedby, saysaria-expandedwhile the calendar is up, and keeps the brand ring the whole time. - The caret stays put. The calendar is a second way to answer, not a takeover, so focus never leaves the field on its own. Arrow Down hands it to the grid; Escape closes and leaves it in the field.
- The shape is stated, not implied. The empty field carries the order to type as its placeholder, so nobody has to guess whether the month or the day comes first.
- The system field carries the label. On the touch path the control is a
real
date(ordatetime-local) input under the field’s surface, so the label, the hint,required, and the first and last day all reach it.
Best practices
- Keep the
Datein state and format at the edge. A picker that hands back strings pushes parsing into every caller. - Bound the calendar instead of validating after the fact:
disableddays cannot be chosen, so there is no error to write. - A date the viewer knows by heart — a birthday, an invoice number’s date —
is faster to type. Reach for an
Inputthere, and keep the picker for dates read off a calendar. - Say what the date means in the label (
Renews on,Ship date), not in the placeholder. The placeholder disappears the moment it is answered.