Field.DateRangePicker
Field.DateRangePicker is a button inside a Field that opens a Calendar in range mode in a Popover. Its value is a DateRange, { start, end } with both days included, or null. The first date picked in the calendar starts a range and the second ends it, in either order; onValueChange then gets the range, and the calendar closes. It takes the calendar's min, max, isDateDisabled, firstDayOfWeek and locale.
On this page
const id = useId();
const [range, setRange] = useState<DateRange | null>(null);
const [text, setText] = useState('');
const [invalid, setInvalid] = useState(false);
<Field.Group>
<Label.Root htmlFor={id}>Stay</Label.Root>
<Field.Root id={id} invalid={invalid}>
<Field.TextInput
value={text}
onChange={(event) => {
setText(event.target.value);
setRange(parseDateRange(event.target.value));
setInvalid(false);
}}
onBlur={() => {
if (range) setText(formatDateRange(range));
else setInvalid(text.trim() !== '');
}}
/>
<Field.DateRangePicker
value={range}
onValueChange={(next) => {
setRange(next);
setText(formatDateRange(next));
setInvalid(false);
}}
showValue={false}
/>
</Field.Root>
</Field.Group>
Here a Field.TextInput in the same field lets people type the range too. A picked range is written into the text with formatDateRange, and typed text that parseDateRange reads as a range moves the calendar to its start. Beside a Field.TextInput, that input is the field's control. It takes the field's id, name and description, and a press elsewhere in the field focuses it. With showValue={false}, the button shows only a calendar glyph. The button sends the range with a form under its own name if it has one, and otherwise under the field's name, unless a Field part or a control joined with useField, other than a Field.ColorPicker, Field.DatePicker, Field.DateRangePicker, Field.TimePicker or Field.DateTimePicker, has that name. To send the range as 2026-10-03/2026-10-10 rather than the typed text, put name on the picker instead of the Root.
Typed ranges
formatDateRange(range, { locale }) writes both dates as formatDate writes them, with the locale's range separator between them, such as 10/3/2026 – 10/10/2026 in en-US and 2026. 10. 3. ~ 2026. 10. 10. in ko. parseDateRange(text, { locale }) reads typed text back into a DateRange, or returns null when the text is not a range. It splits the text at each run of characters that are not letters or digits, and at the locale's range separator, and reads a date on each side with parseDate. It returns the range when exactly one split reads as two dates, so it reads 10/3/2026 - 10/10/2026, Oct 3, 2026 – Oct 10, 2026 and an ISO 8601 interval such as 2026-10-03/2026-10-10. Each date needs its year, as formatDateRange writes them, so it returns null for Oct 3 – Oct 10, 2026. A range typed end first reads with the earlier date as its start, as the calendar picks it. Without locale, both use the runtime's own.
In the example, text that isn't a range clears the range, so the form sends no range, and the field is marked invalid when it loses focus. parseDateRange doesn't check the picker's min, max or isDateDisabled, or that every day between the dates can be picked, so check a typed range yourself.
Presets such as "Last 7 days" are not part of the picker. They are the Date range with presets recipe, which puts buttons beside the picker that set its value.
On its own
On its own, Field.DateRangePicker is the field's control. It shows the range in locale's medium style through Intl.DateTimeFormat's formatRange, such as Oct 3 – 10, 2026, a one-day range as one date, or placeholder while there is none. A press elsewhere in the field opens the calendar. With a name on the Root, the form sends the range as an ISO 8601 interval, such as 2026-10-03/2026-10-10.
The calendar opens and closes through open with onOpenChange, or defaultOpen. onOpenChange gets the reason it closed: 'select' when the range's second date is picked, 'escape', 'outside', 'trigger', or 'disabled' when the field becomes disabled. Open, the calendar puts focus on the range's start, else on today or the nearest date in range. After the first pick, Escape, or a modal's close request such as the Android back gesture, drops it and the calendar stays open; with no first pick, Escape closes the calendar, and focus goes back to where it was before it opened. A first pick is dropped when the calendar closes.
required makes a form with no range invalid. The browser checks it on a native text input that the picker renders beside its button. It is hidden from sight and from assistive technology, is not a tab stop, and is what the form sends under the field's name. Inside a Form.Root, a missing range shows in the field's ErrorMessage.Root in the page's language, and focus goes to the button rather than the hidden input; the summary's row for the field links to the button. Outside a Form.Root the browser blocks the submit and focus lands on the button too. No native input holds a range, so the browser does not check min, max or isDateDisabled; the calendar keeps a picked range within them.
Accessible composition
Todae handles: On its own, the button takes id, aria-label, aria-labelledby and aria-describedby from Field.Root, and native disabled and aria-invalid from the Root or a surrounding Fieldset. Its label names it, so the range it shows is added to its aria-describedby. Beside a Field part or a control joined with useField, other than a Field.ColorPicker, a Field.DatePicker, a Field.TimePicker, a Field.DateTimePicker or another Field.DateRangePicker, such as a text input, that control takes the id and aria-describedby in its place, and the button keeps disabled and aria-invalid and is named from the dateRangePicker string in StringsProvider unless it has its own aria-label. The button gets aria-haspopup="dialog" and aria-expanded, and aria-controls while the calendar is open. The popover is a role="dialog" named from the dateRangePicker string, and the calendar inside is keyed and announced as on Calendar in range mode. The glyph is calendar from GlyphsProvider. A required picker's button is described by the required string in StringsProvider, since a button cannot take aria-required.
You supply: A name, through a Label whose htmlFor is the Root id or aria-label / aria-labelledby on the Root. The same range for a text input and the picker beside it, written with formatDateRange and read with parseDateRange.
Watch for: A server render can't know another control sits beside the button, so until the page hydrates, the button and that control both carry the field's id, and both send a value under the field's name. With a value, the server writes the range in its own locale, so pass locale when the picker renders on a server.
When to use
Field.DateRangePicker
When: A start and an end date in a form, such as a stay, a trip or a report's period.
When not: One date → Field.DatePicker. A range picked from a calendar shown on the page → Calendar with mode="range". A start and an end that people set apart, such as a start date and a separate deadline → two Field.DatePickers.
formatDateRange
When: Writing a range as text for a locale, such as into a text input beside the picker.
When not: A range to read rather than type, such as Oct 3 – 10, 2026 → Intl.DateTimeFormat's formatRange.
parseDateRange
When: Reading a range someone typed.
When not: An ISO 8601 interval from a form or an API, such as 2026-10-03/2026-10-10 → split it at / and read each half with Temporal.PlainDate.from.
Props
| Name | Type | Description |
|---|---|---|
value Required | DateRange | null | The range, or null for none. |
onValueChange | (value: DateRange) => void | Fires with the range picked in the calendar, once its second date is picked. |
min | Temporal.PlainDate | The earliest date that can be picked. |
max | Temporal.PlainDate | The latest date that can be picked. |
required | boolean | A form with no range is invalid. |
isDateDisabled | (date: Temporal.PlainDate) => boolean | Returns true for a date that cannot be picked, such as a holiday. A range can't hold one. |
firstDayOfWeek | 1 | 2 | 3 | 4 | 5 | 6 | 7 | The calendar's first column, 1 (Monday) to 7 (Sunday). The locale's own first day when the browser reports it, else 7. |
locale | string | The locale for the button's dates and the calendar's names, such as "ko". The browser's own when not set, so pass it when the picker renders on a server. |
placeholder | string | Shown on the button while there is no range, when showValue is true. |
showValue | boolean | Whether the button shows the range, or the placeholder, beside its glyph. Set it to false beside a text input that shows the range. Default: true |
open | boolean | Controlled open state of the calendar. Pass with onOpenChange. |
defaultOpen | boolean | Initial open state when uncontrolled. |
onOpenChange | (open: boolean, reason: 'escape' | 'outside' | 'trigger' | 'select' | 'disabled') => void | Fires when the calendar opens or closes, with why it closed. |
| Name | Type | Description |
|---|---|---|
range Required | DateRange | The range to write, as { start, end }. The earlier date comes first, even when it is the end. |
options.locale | string | The locale, such as ko. The runtime's own when not set. |
| Name | Type | Description |
|---|---|---|
text Required | string | The typed text, with a year on each date. |
options.locale | string | The locale, such as ko. The runtime's own when not set. |