Pho Design System

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

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

Best practices