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 receives null once 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: startName and endName submit the two ISO dates.

Example

Statement periodChanged
1 to 30 Sep 2026
Type the dates, or choose them on the calendar.
Follows the page theme and corners
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

ActivityChanged
12 to 18 Sep 2026
Follows the page theme and corners
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

Billing periodChanged
1 to 30 Sep 2026
Trip
14 to 20 Sep 2026
End the trip after it starts.
Contract termChanged
1 Jan to 31 Dec 2026
Follows the page theme and corners
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 null but keeps what is typed, so your null coming back does not clear it. Setting any other value, or focus leaving the field, ends this, so a "Clear all" that sets null still 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 its valueLabel from twelve month names, with no Intl.

Example

Statement periodChanged
Follows the page theme and corners
'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 value is not given.

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 aria-label or an aria-labelledby.

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 onChange sends it. Runs once both dates are complete, alongside React Aria's own checks, so use isRequired for an empty field. Returns an error message, or true or null when valid.

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.

TabThrough the start date segments, the end date segments, then the button.
LeftRightMove between segments, in the drawn direction.
UpDownChange the focused segment by one, wrapping at its ends.
PageUpPageDownChange it by a larger step: a week, two months, or to the next multiple of five years.
HomeEndSet it to its least or greatest value.
0-9Type into the segment. Focus moves on when it is full.
BackspaceDeleteClear its last digit, or move back from an empty segment.
Alt+DownAlt+UpOpen the calendar.
EscapeCloses 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.

esc

↑ ↓ to move↵ to open