A range of two days, typed or chosen on a range calendar, with presets and ISO strings in and out.
Basic
A field for a range of two days. Type each date into its segments, in the locale's order, or choose the range on the calendar. The button at the field's end, or Alt+Arrow Down, opens the calendar on the start month, or on today's month when there is no value.
- Value:
{ start, end }, two ISO dates, in and out. Days only, never times. onChange: receives the range once both dates are complete and the end is on or after the start. It receivesnullonce every segment of a date is cleared, or while the end is before the start.- Clearing one segment keeps the range until the rest of that date is cleared.
- Forms:
startNameandendNamesubmit the two ISO dates.
Example
Example
import { DateRangePicker } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function DateRangePickerBasic(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
<DateRangePicker
label="Statement period"
description="Type the dates, or choose them on the calendar."
defaultValue={{ start: '2026-09-01', end: '2026-09-30' }}
// The app's own words for the range, shown before the page hydrates.
valueLabel="1 to 30 Sep 2026"
today="2026-09-18"
startName="from"
endName="to"
/>
</div>
);
}Presets
presets adds a row of your own ranges above the calendar. The library computes no ranges: compute "Last 7 days" from the app's today, ideally on the server. A press sets the range, closes the calendar and returns focus. A preset is disabled when it falls outside minValue or maxValue, or holds a day isDateUnavailable rejects.
Example
Example
import { DateRangePicker, type DateRangePreset } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** The app's presets, computed on the server from its own today, 2026-09-18, with its own words. */
const PRESETS: DateRangePreset[] = [
{ id: 'last-7', label: 'Last 7 days', range: { start: '2026-09-12', end: '2026-09-18' } },
{ id: 'this-month', label: 'This month', range: { start: '2026-09-01', end: '2026-09-30' } },
{ id: 'last-month', label: 'Last month', range: { start: '2026-08-01', end: '2026-08-31' } },
];
export default function DateRangePickerPresets(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
<DateRangePicker
label="Activity"
defaultValue={{ start: '2026-09-12', end: '2026-09-18' }}
valueLabel="12 to 18 Sep 2026"
today="2026-09-18"
presets={PRESETS}
/>
</div>
);
}States
- Changed: the Changed chip and the light signal orange edge.
- Invalid: the error edge and error line.
- Disabled: recessed, with its dates in taupe.
Example
Example
import { DateRangePicker } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function DateRangePickerStates(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
<DateRangePicker
label="Billing period"
defaultValue={{ start: '2026-09-01', end: '2026-09-30' }}
valueLabel="1 to 30 Sep 2026"
today="2026-09-18"
isChanged
/>
<DateRangePicker
label="Trip"
defaultValue={{ start: '2026-09-14', end: '2026-09-20' }}
valueLabel="14 to 20 Sep 2026"
today="2026-09-18"
isInvalid
errorMessage="End the trip after it starts."
/>
<DateRangePicker
label="Contract term"
defaultValue={{ start: '2026-01-01', end: '2026-12-31' }}
valueLabel="1 Jan to 31 Dec 2026"
today="2026-09-18"
isDisabled
/>
</div>
);
}Before hydration
Pass valueLabel wherever the server's HTML should show the range, written like any other string in the app: "1 to 30 Sep 2026". The segments' text, order and names come from Intl, which the server must not write. Until hydration, the field draws the label, the frame at its final size, the calendar button, the hint, the two hidden inputs and your valueLabel in place of the segments. The segments replace it in the next render without moving anything.
Usage
Keep the range in two search parameters, ?from=2026-09-01&to=2026-09-30. With the Next App Router, a server page reads them and writes valueLabel:
// app/transactions/page.tsx, a server component
import { Period } from './period';
import { periodLabel } from './words'; // the app's own: "1 to 30 Sep 2026"
export default async function Transactions({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
const { from, to } = await searchParams;
return <Period valueLabel={from && to ? periodLabel({ start: from, end: to }) : ''} />;
}The client component keeps the range in its own state, so each change shows at once. Driven by the server's props instead, it would show the old range until the new page arrived.
- It rereads the URL only when the URL changes under it, by Back, Forward or a link.
- On change it sets both parameters, or deletes both for
null, and keeps the other parameters. - It never writes a bare
?, and passes{ scroll: false }so the page does not jump.
'use client';
import { useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { DateRangePicker, type DateRange } from '@snapshot-ui/react';
const rangeOf = (from: string | null, to: string | null): DateRange | null =>
from && to ? { start: from, end: to } : null;
export function Period({ valueLabel }: { valueLabel: string }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const from = searchParams.get('from');
const to = searchParams.get('to');
const key = `${from ?? ''}/${to ?? ''}`;
const [range, setRange] = useState(() => rangeOf(from, to));
const [seen, setSeen] = useState(key);
// The URL changed under the field, by Back, Forward or a link: show its range.
if (key !== seen) {
setSeen(key);
setRange(rangeOf(from, to));
}
return (
<DateRangePicker
label="Statement period"
value={range}
valueLabel={valueLabel}
onChange={(next) => {
setRange(next);
setSeen(next ? `${next.start}/${next.end}` : '/');
const params = new URLSearchParams(searchParams);
if (next === null) {
params.delete('from');
params.delete('to');
} else {
params.set('from', next.start);
params.set('to', next.end);
}
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}}
/>
);
}- A pair that does not parse, or ends before it starts, reads as no range and warns in the console. A hand-edited URL never throws.
- While someone types an end before its start, the field sends
nullbut keeps what is typed, so yournullcoming back does not clear it. Setting any other value, or focus leaving the field, ends this, so a "Clear all" that setsnullstill clears it. - On a statically rendered route, like this page, call
useSearchParams()inside<Suspense>with an empty field as the fallback. The example below does this, and writes itsvalueLabelfrom twelve month names, with noIntl.
Example
Example
'use client';
import { DateRangePicker, type DateRange, type DateRangePreset } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState, type ReactElement } from 'react';
/** The app's presets, computed from its own today, 2026-09-18, with its own words. */
const PRESETS: DateRangePreset[] = [
{ id: 'this-month', label: 'This month', range: { start: '2026-09-01', end: '2026-09-30' } },
{ id: 'last-month', label: 'Last month', range: { start: '2026-08-01', end: '2026-08-31' } },
];
/** The app's own month names: the library writes no date, and the app writes this one with no Intl. */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** "1 to 30 Sep 2026", "28 Aug to 3 Sep 2026" or "28 Dec 2026 to 3 Jan 2027"; empty when either is no ISO date. */
function rangeLabel(range: DateRange | null): string {
const parts = (iso: string): [string, string, string] | null => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
const month = match ? MONTHS[Number(match[2]) - 1] : undefined;
return match?.[1] && match[3] && month ? [String(Number(match[3])), month, match[1]] : null;
};
const start = range && parts(range.start);
const end = range && parts(range.end);
if (!start || !end) return '';
const [startDay, startMonth, startYear] = start;
const [endDay, endMonth, endYear] = end;
let from = startDay;
if (startYear !== endYear) from = `${startDay} ${startMonth} ${startYear}`;
else if (startMonth !== endMonth) from = `${startDay} ${startMonth}`;
return `${from} to ${endDay} ${endMonth} ${endYear}`;
}
/** The two parameters as a range, or null unless both are there. The picker reads a broken one as none. */
const rangeOf = (from: string | null, to: string | null): DateRange | null =>
from && to ? { start: from, end: to } : null;
function PeriodField({
range,
onChange,
}: {
range: DateRange | null;
onChange?: (next: DateRange | null) => void;
}): ReactElement {
return (
<DateRangePicker
label="Statement period"
value={range}
valueLabel={rangeLabel(range)}
today="2026-09-18"
presets={PRESETS}
onChange={onChange}
/>
);
}
function PeriodFromUrl(): ReactElement {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const from = searchParams.get('from');
const to = searchParams.get('to');
const key = `${from ?? ''}/${to ?? ''}`;
const [range, setRange] = useState(() => rangeOf(from, to));
// The parameters the field last took from the URL, or last wrote to it.
const [seen, setSeen] = useState(key);
// The URL changed under the field, by Back, Forward or a link: show its range.
if (key !== seen) {
setSeen(key);
setRange(rangeOf(from, to));
}
return (
<PeriodField
range={range}
onChange={(next) => {
setRange(next);
setSeen(next ? `${next.start}/${next.end}` : '/');
// Start from the parameters already there, so the other filters stay; null deletes both.
const params = new URLSearchParams(searchParams);
if (next === null) {
params.delete('from');
params.delete('to');
} else {
params.set('from', next.start);
params.set('to', next.end);
}
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}}
/>
);
}
export default function DateRangePickerUrl(): ReactElement {
return (
<div className="stack" style={{ width: '100%', maxWidth: '24rem' }}>
{/* The page is static, so the server renders the fallback: the same field, empty. */}
<Suspense fallback={<PeriodField range={null} />}>
<PeriodFromUrl />
</Suspense>
</div>
);
}The field takes no createCalendar. React Aria's date fields always bring every calendar system, so a page with a date range picker ships them all.
Props
Every date is an ISO string. startName, endName, isRequired, isDisabled, isReadOnly, isInvalid, firstDayOfWeek, isOpen, onOpenChange and shouldCloseOnSelect are React Aria's. validate receives the range as onChange does and runs once both dates are complete, so use isRequired to catch an empty field.
aria-label string | Defines a string value that labels the current element. |
|---|---|
changedLabel string | Default: 'Changed' |
className string | ((values: DateRangePickerRenderProps & { defaultClassName: string | undefined; }) => string) | A class name, or a function of the field's state, merged after the library's own classes. |
defaultValue DateRange | null | The range the field starts with when |
description ReactNode | A hint under the field. It becomes the group's accessible description. |
endName string | The name of the end date input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). |
errorMessage ((validation: ValidationResult) => ReactNode) | ReactNode | The error line, shown only while the field is invalid. A function receives the validation result. Without it the field shows its own validation messages. |
isChanged boolean | Whether the value differs from the saved one. Shows the clay Changed chip and the light signal orange edge. A screen reader reads the chip with the group. |
isDateUnavailable ((date: string) => boolean) | Days that cannot be chosen, such as a closed day. Receives an ISO date. |
isDisabled boolean | Whether the input is disabled. |
isInvalid boolean | Whether the input value is invalid. |
isReadOnly boolean | Whether the input can be selected but not changed by the user. |
isRequired boolean | Whether user input is required on the input before form submission. |
label ReactNode | The field's name, shown above it. Required unless the field has an |
labels Partial<DateRangePickerLabels> | The names the field writes, in English unless replaced. |
maxValue string | The latest day that can be chosen, as an ISO date. |
minValue string | The earliest day that can be chosen, as an ISO date. |
onChange ((value: DateRange | null) => void) | Called with the range's two Gregorian ISO dates once both are complete, and with null while one is not. |
placement Placement | Where the calendar opens. Default: 'bottom start' |
popoverClassName string | Goes on the popover around the calendar. |
presets readonly DateRangePreset[] | A row of the app's ranges above the calendar; a press sets the range and closes the calendar. |
startName string | The name of the start date input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname). |
today string | Today in the app's time zone, as an ISO date. Marks today and sets the opening month when there is no value. Defaults to the device's today. |
validate ((value: DateRange | null) => ReturnType<NonNullable<RACDateRangePickerProps<CalendarDate>['validate']>>) | Checks the range as |
value DateRange | null | The range as two ISO dates, 'YYYY-MM-DD', or null for none. Gregorian whatever the calendar shown. |
valueLabel string | The range as the app writes it, shown on the server and until hydration: "1 to 30 Sep 2026". |
end string | Default: 'End date' |
|---|---|
next string | Default: 'Next month' |
open string | The calendar button's name; the field's label follows it. Default: 'Choose dates' |
presets string | The name of the row of presets. Default: 'Presets' |
previous string | Default: 'Previous month' |
start string | The two date fields' names, read with each segment and the field's label. Default: 'Start date' |
Accessibility
The field is a group named by the label. Each segment is a spinbutton named by its type, its date and the label: "month, Start date, Statement period". On iOS, each segment is a text box. The button, named "Choose dates" plus the label, opens a dialog with the range calendar.
"Choose dates", "Start date" and "End date" come from labels. Segment type names come from the browser. React Aria's announcements, such as the chosen range, have no Persian.
| Tab | Through the start date segments, the end date segments, then the button. |
|---|---|
| LeftRight | Move between segments, in the drawn direction. |
| UpDown | Change the focused segment by one, wrapping at its ends. |
| PageUpPageDown | Change it by a larger step: a week, two months, or to the next multiple of five years. |
| HomeEnd | Set it to its least or greatest value. |
| 0-9 | Type into the segment. Focus moves on when it is full. |
| BackspaceDelete | Clear its last digit, or move back from an empty segment. |
| Alt+DownAlt+Up | Open the calendar. |
| Escape | Closes the calendar and returns focus. |
Design rules
What keeps range fields in the system.
Pass valueLabel where the server HTML should show the range.
Compute presets from the today of the app, on the server.
Pass today from the time zone of the app.
Never put a second month beside the first.
Never put times in a range.
Never show a date string the app did not write.